From ac7f8d9685fce54493478394b77add6aa7110391 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 21:06:15 +0200 Subject: [PATCH] Web: HomePage becomes the weekly planning view (step 4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiClient.getCurrentPlanning() — GET /planning/current. - HomePage.tsx: replaces the old greeting card (now redundant with AppLayout's sidebar) with the household's current planning — loading / error / empty ("aucun planning pour cette semaine") / loaded (table of weekDay/meal/recipe) states, modeled as a discriminated union so an impossible combination (e.g. loading with data) can't be represented. No invented weekday/meal grid — the API's `weekDay`/`meal` are free-form strings (no enum exists yet in the schema), so this renders the items as returned rather than assuming a specific vocabulary. - locales/fr/translation.json: home.* replaced (title/loading/error/ empty/table.*), old greeting/logout keys removed (superseded by layout.greeting/layout.logout from the AppLayout commit). - cypress/e2e/auth.cy.ts: updated the now-stale "Bonjour Alice Martin" assertions (greeting moved to the sidebar, first name only) and added GET /planning/current intercepts so these specs don't depend on a real backend. Manually verified end-to-end against a real API in the browser preview (empty state + a seeded planning) — Cypress itself can't run headless Chromium in this sandboxed dev environment (confirmed pre-existing on main, unrelated to this change); CI runs the real suite. Verified with `pnpm --filter web build` after each of steps 2-4 to keep every commit in this sequence independently buildable. --- apps/web/cypress/e2e/auth.cy.ts | 10 ++- apps/web/src/api/client.ts | 6 ++ apps/web/src/locales/fr/translation.json | 11 ++- apps/web/src/pages/HomePage.scss | 77 ++++++++++---------- apps/web/src/pages/HomePage.tsx | 90 ++++++++++++++++++------ 5 files changed, 130 insertions(+), 64 deletions(-) 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/src/api/client.ts b/apps/web/src/api/client.ts index 49e4779..fe90ce7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { 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/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 3fc42bd..5917c23 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -40,8 +40,15 @@ "logout": "Se déconnecter" }, "home": { - "greeting": "Bonjour {{firstName}} {{lastName}} 👋", - "logout": "Se déconnecter" + "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", 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}
+ )} +
); }