Merge pull request #9 from kyuno053/feat/home-planning-sidebar

Home page: sidebar layout + weekly planning
This commit is contained in:
kyuno053 2026-08-16 22:52:33 +02:00 committed by GitHub
commit a14bb4155d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 968 additions and 87 deletions

View file

@ -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
`<Outlet />` 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)

View file

@ -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"

View file

@ -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);
},
);

View file

@ -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.

View file

@ -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<unknown, AuthLocals>(async (_req, res) => {
const planning = await getCurrentPlanning(res.locals.userProfile.houseId);
res.status(200).json(planning);
}),
);

View file

@ -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<PlanningView | null> {
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,
})),
};
}

View file

@ -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);
});
});
});

View file

@ -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("/");

View file

@ -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");
});
});

View file

@ -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 (
<Routes>
<Route
path="/"
element={
<RequireAuth>
<HomePage />
<AppLayout />
</RequireAuth>
}
/>
>
<Route path="/" element={<HomePage />} />
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
</Route>
<Route
path="/login"
element={

View file

@ -2,6 +2,7 @@ import {
type ApiErrorResponse,
ErrorCode,
type LoginInput,
type PlanningView,
type SafeUserProfile,
type SignupInput,
} from "@batch-cooking/shared";
@ -93,6 +94,11 @@ export class ApiClient {
public me(): Promise<SafeUserProfile> {
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<PlanningView | null> {
return this.request("/planning/current");
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -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;
}
}
}

View file

@ -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.<key>` 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 `<Outlet />`.
*
* 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 (
<div className="app-layout">
<aside className="app-sidebar">
<div className="app-sidebar__brand">batchCooking</div>
<nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key }) => (
<NavLink
key={to}
to={to}
// Only the home ("/") entry needs exact matching — every
// other path is a leaf with nothing nested under it (yet),
// so NavLink's default prefix matching already behaves the
// same way `end` would.
end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{t(`layout.nav.${key}`)}
</NavLink>
))}
</nav>
<div className="app-sidebar__footer">
<span className="app-sidebar__user">
{t("layout.greeting", { firstName: user?.firstName })}
</span>
<button type="button" onClick={handleLogout}>
{t("layout.logout")}
</button>
</div>
</aside>
<main className="app-content">
<Outlet />
</main>
</div>
);
}

View file

@ -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."
}
}

View file

@ -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);
}
}

View file

@ -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 (
<div className="coming-soon-page">
<h1>{title}</h1>
<p>{description}</p>
</div>
);
}

View file

@ -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;
}
}

View file

@ -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<PlanningState>({ 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 (
<main className="home-page">
<div className="home-card">
<h1>batchCooking</h1>
<p>{t("home.greeting", { firstName: user?.firstName, lastName: user?.lastName })}</p>
<button type="button" onClick={handleLogout}>
{t("home.logout")}
</button>
</div>
</main>
<div className="home-page">
<h1>{t("home.title")}</h1>
{state.status === "loading" && <p className="home-page__status">{t("home.loading")}</p>}
{state.status === "error" && (
<p className="home-page__status home-page__status--error">{t("home.error")}</p>
)}
{state.status === "loaded" && state.planning === null && (
<p className="home-page__status">{t("home.empty")}</p>
)}
{state.status === "loaded" && state.planning !== null && (
<table className="planning-table">
<thead>
<tr>
<th>{t("home.table.day")}</th>
<th>{t("home.table.meal")}</th>
<th>{t("home.table.recipe")}</th>
</tr>
</thead>
<tbody>
{state.planning.items.map((item) => (
<tr key={item.id}>
<td>{item.weekDay}</td>
<td>{item.meal}</td>
<td>{item.recipe.name}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -0,0 +1,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 <ComingSoonPage title={t("household.title")} description={t("household.comingSoon")} />;
}

View file

@ -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 <ComingSoonPage title={t("recipes.title")} description={t("recipes.comingSoon")} />;
}

View file

@ -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 (
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
);
}

View file

@ -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<string, any>`, matching Express's own
* `Response<ResBody, LocalsObj>` exactly (see `@types/express-serve-static-
* core`) rather than the stricter `Record<string, unknown>`: 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<string, unknown> = Record<string, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: mirrors Express's own Response<ResBody, LocalsObj extends Record<string, any>> constraint (see comment above) — `unknown` here would reject plain interfaces like AuthLocals that Response itself accepts fine.
Locals extends Record<string, any> = Record<string, any>,
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
/**
@ -26,7 +35,8 @@ export type AsyncRequestHandler<
*/
export function wrapAsyncHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: same constraint as AsyncRequestHandler above, for the same reason.
Locals extends Record<string, any> = Record<string, any>,
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
return (req, res, next) => {
handler(req, res as Response<ResBody, Locals>, next).catch(next);

View file

@ -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";

View file

@ -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[];
}

View file

@ -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<string, any>`, pas `unknown`
Le paramètre générique `Locals` est contraint par `Record<string, any>`, à
l'identique du propre `Response<ResBody, LocalsObj>` d'Express
(`@types/express-serve-static-core`) — volontairement, pas `Record<string,
unknown>` (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<unknown, AuthLocals>(...)` 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

View file

@ -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
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<HomePage />} />
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
</Route>
```
`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections,
nom de l'utilisateur + déconnexion en pied de sidebar) et un `<main>` qui affiche
la route enfant matchée via `<Outlet />` — 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/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.