Web: HomePage becomes the weekly planning view (step 4/5)

- 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.
This commit is contained in:
Nicolas 2026-08-16 21:06:15 +02:00
parent c07f8ad82e
commit ac7f8d9685
5 changed files with 130 additions and 64 deletions

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

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

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

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