diff --git a/README.md b/README.md index d5bfbc7..9e40008 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,24 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute > (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle > efface tes données de test sans prévenir. Pas un bug, juste à savoir. +## Planning (apps/api) + +- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le + planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning` + dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette + résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en + cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une + erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul + batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md), + reste à construire), donc c'est l'état attendu tant que ce module n'existe pas. +- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`), + consommé tel quel par `apps/web`. + +Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) — +premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui +a mis au jour une contrainte générique trop stricte, corrigée à la source : +[specs/backend-architecture.md](specs/backend-architecture.md). + ## Page de connexion / inscription (apps/web) - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) : @@ -186,11 +204,33 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute Détail de l'organisation complète (dossiers, routing, SCSS/theming) : [specs/frontend-architecture.md](specs/frontend-architecture.md). -Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` mockent l'API via -`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne -provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement -réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une vraie -base). +## Accueil, sidebar & sections (apps/web) + +Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar +(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et +`` pour la route active — montée une seule fois comme route parente de tout +l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx` +(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`, +voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes`, `Liste de +courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour +l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule +route parente, pourquoi un composant stub partagé) : +[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté). + +Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` + +`home-planning.cy.ts` mockent l'API via `cy.intercept` plutôt que de dépendre d'un +vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le +serveur de dev Vite. Le comportement réel de l'API est couvert par les suites +Mocha/Cucumber d'`apps/api` (contre une vraie base). + +> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** : +> Chromium/Electron headless plante au lancement du process GPU +> (`GPU process isn't usable`), reproductible sur `main` aussi bien que sur une +> branche de feature — pas un problème introduit par une modification du code. +> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une +> machine de dev classique ; dans cet environnement précis, vérifier manuellement via +> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas les conteneurs +> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`). ## Gestion des erreurs (API ↔ web) diff --git a/apps/api/features/planning.feature b/apps/api/features/planning.feature new file mode 100644 index 0000000..5810a91 --- /dev/null +++ b/apps/api/features/planning.feature @@ -0,0 +1,24 @@ +Feature: Household weekly planning + As a signed-in user + I want to see my household's current planning + So that I know what meals are planned this week + + Scenario: A visitor without a session cannot view the planning + When I request the current planning + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user with no planning yet sees an empty state + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I request the current planning + Then the response status should be 200 + And the current planning response should be empty + + Scenario: A signed-in user sees their household's current planning + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner" + When I request the current planning + Then the response status should be 200 + And the current planning response should include recipe "Ratatouille" on "monday" for "dinner" diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts new file mode 100644 index 0000000..67a25b1 --- /dev/null +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { Given, Then, When } from "@cucumber/cucumber"; +import { prisma } from "../../src/db/prisma.js"; +import type { CustomWorld } from "../support/world.js"; + +When("I request the current planning", async function (this: CustomWorld) { + this.response = await this.agent.get("/planning/current"); +}); + +Then("the current planning response should be empty", function (this: CustomWorld) { + assert.equal(this.response.body, null); +}); + +// Creates the planning/recipe rows directly via Prisma rather than through +// the API — there's no "create a planning" endpoint yet (see +// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still +// TODO), so this is the only way to get a household into a state where it +// has one. Reads the household off the already-authenticated agent (via +// `GET /auth/me`) rather than taking it as a step argument, since the +// scenario never names it explicitly. +Given( + "my household has a planning covering today with recipe {string} on {string} for {string}", + async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { + const me = await this.agent.get("/auth/me"); + const houseId: number = me.body.houseId; + + const recipe = await prisma.recipe.create({ data: { name: recipeName } }); + const today = new Date(); + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + ), + finishDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), + ), + }, + }); + await prisma.planningItem.create({ + data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id }, + }); + }, +); + +Then( + "the current planning response should include recipe {string} on {string} for {string}", + function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { + const items = this.response.body.items as Array<{ + weekDay: string; + meal: string; + recipe: { name: string }; + }>; + const item = items.find((i) => i.recipe.name === recipeName); + assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`); + assert.equal(item.weekDay, weekDay); + assert.equal(item.meal, meal); + }, +); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8e5c08a..63baf0f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { ErrorCode } from "@batch-cooking/shared"; import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { planningRouter } from "./modules/planning/planning.routes.js"; /** * Builds the API's `ExpressServer`: standard middleware, routes, and the @@ -22,6 +23,7 @@ export function createServer(): ExpressServer { }); server.mountRouter("/auth", authRouter); + server.mountRouter("/planning", planningRouter); // No route matched — same shape as every other error response, via the // shared ErrorCode contract, so clients never special-case 404s. diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts new file mode 100644 index 0000000..278cf2e --- /dev/null +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -0,0 +1,21 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getCurrentPlanning } 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}). + */ +planningRouter.get( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const planning = await getCurrentPlanning(res.locals.userProfile.houseId); + 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 new file mode 100644 index 0000000..67eda25 --- /dev/null +++ b/apps/api/src/modules/planning/planning.service.ts @@ -0,0 +1,60 @@ +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}`). + * + * 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. + */ +export async function getCurrentPlanning(houseId: number | null): 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()), + ); + + const planning = await prisma.planning.findFirst({ + where: { + houseId, + startDate: { lte: todayDateOnly }, + finishDate: { gte: todayDateOnly }, + }, + // A household should never have two plannings covering the same day, + // but nothing in the schema enforces that yet — pick the most recently + // started one rather than letting the query fail if it ever happens. + orderBy: { startDate: "desc" }, + include: { + items: { + include: { recipe: { select: { id: true, name: true } } }, + }, + }, + }); + + if (!planning) { + return null; + } + + return { + id: planning.id, + startDate: planning.startDate.toISOString(), + finishDate: planning.finishDate.toISOString(), + items: planning.items.map((item) => ({ + id: item.id, + weekDay: item.weekDay, + meal: item.meal, + recipe: item.recipe, + })), + }; +} diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts new file mode 100644 index 0000000..f83395e --- /dev/null +++ b/apps/api/test/planning.test.ts @@ -0,0 +1,101 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +describe("Planning", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /planning/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/planning/current"); + + 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 () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/planning/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.equal(null); + }); + + it("returns the household's planning covering today, with recipes resolved", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + const houseId: number = signupRes.body.houseId; + + const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); + const today = new Date(); + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + ), + finishDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), + ), + }, + }); + await prisma.planningItem.create({ + data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id }, + }); + + const res = await agent.get("/planning/current"); + + 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].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); + }); + + it("returns null when the household's planning does not cover today", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + const houseId: number = signupRes.body.houseId; + + // A planning entirely in the past — shouldn't be picked up as "current". + await prisma.planning.create({ + data: { + houseId, + startDate: new Date(Date.UTC(2000, 0, 1)), + finishDate: new Date(Date.UTC(2000, 0, 7)), + }, + }); + + const res = await agent.get("/planning/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.equal(null); + }); + }); +}); diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts index 825ddb0..0f5db35 100644 --- a/apps/web/cypress/e2e/auth.cy.ts +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -8,6 +8,7 @@ import { ErrorCode } from "@batch-cooking/shared"; describe("Signup", () => { it("creates a profile and lands on the home page", () => { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: { @@ -30,7 +31,7 @@ describe("Signup", () => { cy.wait("@signup"); cy.url().should("not.include", "/signup"); - cy.contains("Bonjour Alice Martin").should("be.visible"); + cy.contains("Bonjour Alice").should("be.visible"); }); it("shows a client-side validation error without calling the API", () => { @@ -70,6 +71,7 @@ describe("Signup", () => { describe("Login", () => { it("logs in and lands on the home page", () => { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/login", { statusCode: 200, body: { @@ -89,7 +91,7 @@ describe("Login", () => { cy.contains("button", "Se connecter").click(); cy.wait("@login"); - cy.contains("Bonjour Alice Martin").should("be.visible"); + cy.contains("Bonjour Alice").should("be.visible"); }); it("shows an error on invalid credentials", () => { @@ -123,10 +125,11 @@ describe("Already authenticated", () => { dietId: null, }, }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.visit("/login"); cy.url().should("not.include", "/login"); - cy.contains("Bonjour Alice Martin").should("be.visible"); + cy.contains("Bonjour Alice").should("be.visible"); }); it("logs out and returns to the login page", () => { @@ -142,6 +145,7 @@ describe("Already authenticated", () => { dietId: null, }, }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); cy.visit("/"); diff --git a/apps/web/cypress/e2e/home-planning.cy.ts b/apps/web/cypress/e2e/home-planning.cy.ts new file mode 100644 index 0000000..7f336c0 --- /dev/null +++ b/apps/web/cypress/e2e/home-planning.cy.ts @@ -0,0 +1,106 @@ +// 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("/"); + }); + + 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", "Foyer & profil").click(); + cy.url().should("include", "/foyer"); + cy.contains("h1", "Foyer & profil").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 sidebar", () => { + cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); + + cy.contains("Bonjour Alice").should("be.visible"); + 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/src/App.tsx b/apps/web/src/App.tsx index 2d62989..3709ca9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,27 +1,37 @@ 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 { HouseholdPage } from "./pages/HouseholdPage"; import { LoginPage } from "./pages/LoginPage"; +import { RecipesPage } from "./pages/RecipesPage"; +import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; /** - * Top-level route table. `/` requires an authenticated session (see - * {@link RequireAuth}); `/login` and `/signup` redirect an already-logged-in - * visitor to `/` instead (see {@link RedirectIfAuthenticated}). Anything - * else falls back to `/`, which itself redirects to `/login` if needed. + * Top-level route table. Every authenticated section is nested under one + * `RequireAuth` + `AppLayout` parent route (sidebar chrome + `/auth` + * guard applied once, not per-page — see {@link AppLayout}); `/login` and + * `/signup` redirect an already-logged-in visitor to `/` instead (see + * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which + * itself redirects to `/login` if needed. */ export function App() { return ( - + } - /> + > + } /> + } /> + } /> + } /> + { return this.request("/auth/me"); } + + /** 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"); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/layouts/AppLayout.scss b/apps/web/src/layouts/AppLayout.scss new file mode 100644 index 0000000..1cc7dfe --- /dev/null +++ b/apps/web/src/layouts/AppLayout.scss @@ -0,0 +1,134 @@ +// ============================================================================= +// Styles for AppLayout — the shell (sidebar + content area) wrapping every +// authenticated page. Colocated next to AppLayout.tsx since nothing else +// uses these classes. +// ============================================================================= + +// No `@use` of the theme partial needed here — see HomePage.scss's identical +// note: every token below is a CSS custom property, available at runtime. + +.app-layout { + min-height: 100vh; + display: flex; + background: var(--color-background); +} + +// Fixed-width nav rail. Its own surface (not the page background), same +// elevation language as a card, so it reads as a distinct, permanent piece +// of chrome rather than part of the scrolling content. +.app-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 15rem; + padding: var(--space-lg) var(--space-md); + background: var(--color-surface); + border-right: 1px solid var(--color-border); + + &__brand { + padding: 0 var(--space-sm) var(--space-xl); + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-primary); + } + + &__nav { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-xs); + + a { + padding: var(--space-sm); + border-radius: var(--radius-base); + color: var(--color-text); + font-weight: 600; + font-size: var(--font-size-sm); + text-decoration: none; + + &:hover { + background: var(--color-surface-alt); + } + + // Applied by NavLink itself (see the `className` prop in + // AppLayout.tsx), not a router-provided class — plain `.active`. + &.active { + background: var(--color-primary); + color: #fff; + } + } + } + + &__footer { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); + } + + &__user { + padding: 0 var(--space-sm); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + + &__footer button { + padding: 0.5rem var(--space-sm); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-base); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + + &:hover { + background: var(--color-surface-alt); + } + } +} + +.app-content { + flex: 1; + // Content can scroll independently of the sidebar (e.g. a long planning + // table) without the fixed-width rail ever needing to shrink. + min-width: 0; + padding: var(--space-xl); +} + +// Below this width the fixed-width rail would eat too much of the +// viewport (relevant early given this app is meant to be embedded via +// Capacitor later, see the root README) — collapse it into a horizontal +// top bar instead of a side rail. +@media (max-width: 640px) { + .app-layout { + flex-direction: column; + } + + .app-sidebar { + flex-direction: row; + align-items: center; + width: 100%; + padding: var(--space-sm) var(--space-md); + border-right: none; + border-bottom: 1px solid var(--color-border); + + &__brand { + padding: 0 var(--space-sm) 0 0; + } + + &__nav { + flex-direction: row; + overflow-x: auto; + } + + &__footer { + flex-direction: row; + padding-top: 0; + border-top: none; + } + } +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..4497971 --- /dev/null +++ b/apps/web/src/layouts/AppLayout.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../features/auth/AuthContext"; +import "./AppLayout.scss"; + +/** + * One entry in the sidebar nav. `key` maps to `layout.nav.` in + * `locales/fr/translation.json` — adding a section is one array entry plus + * one locale key, no other file to touch. + */ +const NAV_ITEMS = [ + { to: "/", key: "planning" }, + { to: "/recettes", key: "recipes" }, + { to: "/liste-de-courses", key: "shoppingList" }, + { to: "/foyer", key: "household" }, +] as const; + +/** + * Shell for every authenticated page: a sidebar (brand, section nav, and + * the signed-in user + logout at the bottom) plus a main content area + * rendering the matched child route via ``. + * + * Mounted once as the parent element of the whole authenticated route + * group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is + * therefore guaranteed non-null by the time this renders. + */ +export function AppLayout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + /** Ends the session and returns to the login page. */ + async function handleLogout() { + await logout(); + navigate("/login"); + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index b343580..5917c23 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -29,8 +29,37 @@ "loginLink": "Se connecter" } }, - "home": { - "greeting": "Bonjour {{firstName}} {{lastName}} 👋", + "layout": { + "nav": { + "planning": "Planning", + "recipes": "Recettes", + "shoppingList": "Liste de courses", + "household": "Foyer & profil" + }, + "greeting": "Bonjour {{firstName}} 👋", "logout": "Se déconnecter" + }, + "home": { + "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" + } + }, + "recipes": { + "title": "Recettes", + "comingSoon": "Cette section arrive bientôt." + }, + "shoppingList": { + "title": "Liste de courses", + "comingSoon": "Cette section arrive bientôt." + }, + "household": { + "title": "Foyer & profil", + "comingSoon": "Cette section arrive bientôt." } } diff --git a/apps/web/src/pages/ComingSoonPage.scss b/apps/web/src/pages/ComingSoonPage.scss new file mode 100644 index 0000000..122f5dc --- /dev/null +++ b/apps/web/src/pages/ComingSoonPage.scss @@ -0,0 +1,12 @@ +// ============================================================================= +// Styles for ComingSoonPage — shared by every stub section page. +// ============================================================================= + +.coming-soon-page { + max-width: 40rem; + + p { + color: var(--color-text-muted); + font-size: var(--font-size-md); + } +} diff --git a/apps/web/src/pages/ComingSoonPage.tsx b/apps/web/src/pages/ComingSoonPage.tsx new file mode 100644 index 0000000..0d5a5df --- /dev/null +++ b/apps/web/src/pages/ComingSoonPage.tsx @@ -0,0 +1,23 @@ +import "./ComingSoonPage.scss"; + +interface ComingSoonPageProps { + title: string; + description: string; +} + +/** + * Placeholder rendered by every section that has a route/sidebar entry but + * no real feature behind it yet (Recettes, Liste de courses, Foyer & + * profil — see `RecipesPage.tsx` etc.). One shared component instead of + * three near-identical markup blocks; each page still gets its own file + * (and its own copy, via i18n) so building out a real feature later means + * rewriting one dedicated file, not splitting a generic route. + */ +export function ComingSoonPage({ title, description }: ComingSoonPageProps) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/apps/web/src/pages/HomePage.scss b/apps/web/src/pages/HomePage.scss index a2e8260..656e51c 100644 --- a/apps/web/src/pages/HomePage.scss +++ b/apps/web/src/pages/HomePage.scss @@ -8,48 +8,49 @@ // styles/global.scss and available globally at runtime — not a Sass-level // variable/mixin that would require an explicit compile-time import. -// Full-viewport centering wrapper, mirroring .auth-page's layout so the app -// doesn't visually jump between the login/signup screens and the home page. +// 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 { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md); - background: var(--color-background); -} - -// The greeting itself sits on its own surface, same treatment as the auth -// card, so the two screens read as one coherent app rather than two. -.home-card { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-md); - padding: var(--space-xl); - background: var(--color-surface); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - text-align: center; - - p { + &__status { color: var(--color-text-muted); font-size: var(--font-size-md); } - button { - padding: 0.6rem var(--space-lg); - font-family: var(--font-body); - font-size: var(--font-size-base); - font-weight: 600; - cursor: pointer; - border-radius: var(--radius-base); - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text); - - &:hover { - background: var(--color-surface-alt); - } + &__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 index 525ba72..4181fa6 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,33 +1,81 @@ +import type { PlanningView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { useAuth } from "../features/auth/AuthContext"; +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. Behind {@link RequireAuth} — - * `user` is guaranteed non-null by the time this renders. Static copy - * comes from i18next (`locales/fr/translation.json`, `home` namespace). + * 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 { user, logout } = useAuth(); - const navigate = useNavigate(); const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); - /** Ends the session and returns to the login page. */ - async function handleLogout() { - await logout(); - navigate("/login"); - } + 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 ( -
-
-

batchCooking

-

{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}

- -
-
+
+

{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/HouseholdPage.tsx b/apps/web/src/pages/HouseholdPage.tsx new file mode 100644 index 0000000..8502f37 --- /dev/null +++ b/apps/web/src/pages/HouseholdPage.tsx @@ -0,0 +1,8 @@ +import { useTranslation } from "react-i18next"; +import { ComingSoonPage } from "./ComingSoonPage"; + +/** Household & profile section — routed at `/foyer`. No backend yet beyond auth, stub for now. */ +export function HouseholdPage() { + const { t } = useTranslation(); + return ; +} diff --git a/apps/web/src/pages/RecipesPage.tsx b/apps/web/src/pages/RecipesPage.tsx new file mode 100644 index 0000000..e7080fc --- /dev/null +++ b/apps/web/src/pages/RecipesPage.tsx @@ -0,0 +1,8 @@ +import { useTranslation } from "react-i18next"; +import { ComingSoonPage } from "./ComingSoonPage"; + +/** Recipes section — routed at `/recettes`. No backend yet (see README's "Planning" section), stub for now. */ +export function RecipesPage() { + const { t } = useTranslation(); + return ; +} diff --git a/apps/web/src/pages/ShoppingListPage.tsx b/apps/web/src/pages/ShoppingListPage.tsx new file mode 100644 index 0000000..090be39 --- /dev/null +++ b/apps/web/src/pages/ShoppingListPage.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from "react-i18next"; +import { ComingSoonPage } from "./ComingSoonPage"; + +/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */ +export function ShoppingListPage() { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/packages/express-tools/src/async-handler.ts b/packages/express-tools/src/async-handler.ts index 1dcd692..0406e9e 100644 --- a/packages/express-tools/src/async-handler.ts +++ b/packages/express-tools/src/async-handler.ts @@ -5,10 +5,19 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; * Only `ResBody`/`Locals` are made generic (what this codebase actually * varies per-route) — params/request-body/query stay at Express's own * internal defaults, same as an unparameterized `Request`. + * + * `Locals` is constrained to `Record`, matching Express's own + * `Response` exactly (see `@types/express-serve-static- + * core`) rather than the stricter `Record`: a plain + * `interface` (e.g. `AuthLocals` in `require-auth.ts`) has no index + * signature, so under `unknown` it fails this generic's constraint even + * though it's assignable to `Response`'s own `Locals` param directly — + * `any` is what lets that structural gap close. */ export type AsyncRequestHandler< ResBody = unknown, - Locals extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: mirrors Express's own Response> constraint (see comment above) — `unknown` here would reject plain interfaces like AuthLocals that Response itself accepts fine. + Locals extends Record = Record, > = (req: Request, res: Response, next: NextFunction) => Promise; /** @@ -26,7 +35,8 @@ export type AsyncRequestHandler< */ export function wrapAsyncHandler< ResBody = unknown, - Locals extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: same constraint as AsyncRequestHandler above, for the same reason. + Locals extends Record = Record, >(handler: AsyncRequestHandler): RequestHandler { return (req, res, next) => { handler(req, res as Response, next).catch(next); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 98ed583..f0dc809 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,4 +6,5 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/planning.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/types/planning.ts b/packages/shared/src/types/planning.ts new file mode 100644 index 0000000..74a7c99 --- /dev/null +++ b/packages/shared/src/types/planning.ts @@ -0,0 +1,28 @@ +/** + * A single meal slot within a household's planning, with its recipe + * resolved to just enough info for display (id + name) — a caller needing + * more than that fetches the recipe itself separately. + */ +export interface PlanningItemView { + id: number; + /** Day of the week this item falls on (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). */ + meal: string; + recipe: { + id: number; + name: string; + }; +} + +/** + * A household's planning for a date range, as returned by the API. Dates + * are ISO 8601 strings (JSON has no native date type) — parse with + * `new Date(...)` client-side if arithmetic is needed. + */ +export interface PlanningView { + id: number; + startDate: string; + finishDate: string; + items: PlanningItemView[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index a0f39be..89ce0da 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -54,6 +54,22 @@ Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au middleware d'erreur d'Express — chaque route devait faire son propre `try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise. +### `AsyncRequestHandler`/`wrapAsyncHandler` — `Locals` contraint par `Record`, pas `unknown` + +Le paramètre générique `Locals` est contraint par `Record`, à +l'identique du propre `Response` d'Express +(`@types/express-serve-static-core`) — volontairement, pas `Record` (plus strict, ce qui serait la contrainte "par défaut" attendue). +Raison concrète : une `interface` sans signature d'index (ex. `AuthLocals` +dans `require-auth.ts`) échoue la contrainte générique sous `unknown` alors +qu'elle s'assigne très bien à `Response`'s own `Locals` param directement — +observé en committant `wrapAsyncHandler(...)` sur +`GET /planning/current` (premier endpoint à combiner authentification et +handler async). `any` referme cet écart structurel ; les deux occurrences +portent un commentaire `biome-ignore lint/suspicious/noExplicitAny` expliquant +pourquoi (le lint interdit `any` par défaut, à raison, mais ce cas précis +imite un type de la lib standard Express qui fait le même choix). + ### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools` Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index 4348234..3933206 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -14,7 +14,7 @@ apps/web/src/ ├── i18n/ │ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord ├── locales/ -│ └── fr/translation.json # libellés français (errors.*, auth.*, home.*) +│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*) ├── services/ │ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next ├── features/ @@ -23,10 +23,14 @@ apps/web/src/ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup) │ └── auth-form.scss # styles partagés par LoginPage et SignupPage +├── layouts/ +│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas ├── pages/ │ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé) │ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé) -│ └── HomePage.tsx + HomePage.scss +│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/") +│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore +│ ├── RecipesPage.tsx / ShoppingListPage.tsx / HouseholdPage.tsx # fines enveloppes autour de ComingSoonPage ├── styles/ │ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) │ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx) @@ -56,11 +60,15 @@ flowchart TB CHECK -->|"200 (session valide)"| AUTHED["user défini"] CHECK -->|"401 (pas de session)"| ANON["user = null"] - AUTHED --> ROUTE_HOME["/ → HomePage"] + AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"] + LAYOUT --> ROUTE_HOME["/ → HomePage (planning)"] + LAYOUT --> ROUTE_RECIPES["/recettes → RecipesPage"] + LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"] + LAYOUT --> ROUTE_HOUSEHOLD["/foyer → HouseholdPage"] AUTHED --> ROUTE_LOGIN_A["/login ou /signup"] ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME - ANON --> ROUTE_HOME_A["/"] + ANON --> ROUTE_HOME_A["/, /recettes, ..."] ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"] ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"] ``` @@ -69,10 +77,45 @@ flowchart TB fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui permet à un rechargement de page de garder l'utilisateur connecté. - `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route - (`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde - protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`). - Les deux affichent `null` tant que la vérification initiale est en cours, pour - éviter un flash de contenu suivi d'une redirection. + (`react-router-dom`) qui lisent cet état : la première protège tout l'espace + connecté (voir `AppLayout` ci-dessous), la seconde protège `/login` et `/signup` + (redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant + que la vérification initiale est en cours, pour éviter un flash de contenu suivi + d'une redirection. + +--- + +## `AppLayout` — sidebar commune à l'espace connecté + +`App.tsx` monte **un seul** `RequireAuth` + `AppLayout` comme route parente de +toutes les routes authentifiées (routes imbriquées `react-router-dom`) : + +```tsx +}> + } /> + } /> + } /> + } /> + +``` + +`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections, +nom de l'utilisateur + déconnexion en pied de sidebar) et un `
` qui affiche +la route enfant matchée via `` — la garde d'auth et le chrome de +navigation ne sont donc écrits qu'une fois, pas dupliqués par page comme +`RequireAuth` l'était individuellement avant cette feature. En dessous de 640px la +sidebar devient une barre horizontale (voir `AppLayout.scss`) — pertinent tôt +puisque l'app est prévue pour être embarquée par Capacitor plus tard (voir le +README racine). + +### Sections sans backend — `ComingSoonPage` + +`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend +dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa +propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la +vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une +route générique), mais toutes rendent le même composant `ComingSoonPage` +(`title`/`description`) pour éviter de tripler un même bloc de markup. --- @@ -101,7 +144,9 @@ JSON, jamais codé en dur dans un composant. une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu. - `locales/fr/translation.json` — toutes les chaînes françaises, organisées par namespace : `errors.*` (voir [error-handling.md](./error-handling.md)), - `auth.login.*` / `auth.signup.*`, `home.*`. + `auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation, + déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*` + / `household.*` (copie des pages stub, voir `ComingSoonPage` plus haut). - Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`. - Ajouter une langue : créer `locales//translation.json` avec les mêmes clés, ajouter `resources.` dans `i18n/i18n.ts` — aucun composant à toucher.