From e3811dc2809c834c210b8aee3f25e8249c915921 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 15:55:18 +0200 Subject: [PATCH 1/6] =?UTF-8?q?Shared=20+=20migration:=20table=20user=5Fpr?= =?UTF-8?q?eference=20(th=C3=A8me=20clair/sombre/syst=C3=A8me)=20(step=201?= =?UTF-8?q?/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modèle Prisma UserPreference (1-1 avec UserProfile, onDelete Cascade), enum ThemePreference (LIGHT/DARK/SYSTEM, défaut SYSTEM) - packages/shared: THEME_PREFERENCES/ThemePreference, PreferencesView, updatePreferencesSchema --- .../migration.sql | 13 ++++++++++ apps/api/prisma/schema.prisma | 26 +++++++++++++++++++ packages/shared/src/index.ts | 2 ++ packages/shared/src/schemas/preferences.ts | 11 ++++++++ packages/shared/src/types/preferences.ts | 15 +++++++++++ 5 files changed, 67 insertions(+) create mode 100644 apps/api/prisma/migrations/20260817120000_add_user_preference/migration.sql create mode 100644 packages/shared/src/schemas/preferences.ts create mode 100644 packages/shared/src/types/preferences.ts diff --git a/apps/api/prisma/migrations/20260817120000_add_user_preference/migration.sql b/apps/api/prisma/migrations/20260817120000_add_user_preference/migration.sql new file mode 100644 index 0000000..f888721 --- /dev/null +++ b/apps/api/prisma/migrations/20260817120000_add_user_preference/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "ThemePreference" AS ENUM ('LIGHT', 'DARK', 'SYSTEM'); + +-- CreateTable +CREATE TABLE "user_preference" ( + "user_profile_id" INTEGER NOT NULL, + "theme" "ThemePreference" NOT NULL DEFAULT 'SYSTEM', + + CONSTRAINT "user_preference_pkey" PRIMARY KEY ("user_profile_id") +); + +-- AddForeignKey +ALTER TABLE "user_preference" ADD CONSTRAINT "user_preference_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 769943d..f398ab4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -98,10 +98,36 @@ model UserProfile { /// a time — but Prisma models the admin side of a one-to-many FK as a /// list regardless of that real-world cardinality. administeredHouses House[] @relation("HouseAdmin") + preferences UserPreference? @@map("user_profiles") } +/// Not in the original spec doc — personalization settings (theme for now, +/// meant to grow), one row per profile, created on demand (see +/// `preferences.service.ts`) rather than at signup — same "absent means the +/// default" philosophy as `dietId`/allergies. +enum ThemePreference { + LIGHT + DARK + /// Follow the OS/browser preference — the default. Not "no row yet" (that + /// case is handled in the service layer) but an explicit choice to track + /// the system, distinguishable from a user who hasn't decided yet if this + /// model ever needs that distinction. + SYSTEM +} + +model UserPreference { + /// Both the primary key and the FK — a strict 1-1 with UserProfile, no + /// separate auto-incrementing id (a profile has at most one preferences row). + userProfileId Int @id @map("user_profile_id") + theme ThemePreference @default(SYSTEM) + + userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) + + @@map("user_preference") +} + /// Explicit join table for the user_profiles <-> allergy association /// (documented in the spec as a plain many-to-many, no extra fields). model UserProfileAllergy { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3a7f5e4..d7a446f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,9 +8,11 @@ export * from "./schemas/account.js"; export * from "./schemas/auth.js"; export * from "./schemas/household.js"; export * from "./schemas/planning.js"; +export * from "./schemas/preferences.js"; export * from "./schemas/profile.js"; export * from "./tools/assert-is-never.js"; export * from "./types/household.js"; export * from "./types/planning.js"; +export * from "./types/preferences.js"; export * from "./types/reference.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/preferences.ts b/packages/shared/src/schemas/preferences.ts new file mode 100644 index 0000000..c91e0e5 --- /dev/null +++ b/packages/shared/src/schemas/preferences.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { THEME_PREFERENCES } from "../types/preferences.js"; + +// See schemas/auth.ts for the shared client/server validation rationale. + +/** Payload accepted by `PATCH /preferences`. */ +export const updatePreferencesSchema = z.object({ + theme: z.enum(THEME_PREFERENCES), +}); +/** Inferred TS type for {@link updatePreferencesSchema}'s validated output. */ +export type UpdatePreferencesInput = z.infer; diff --git a/packages/shared/src/types/preferences.ts b/packages/shared/src/types/preferences.ts new file mode 100644 index 0000000..dc3804b --- /dev/null +++ b/packages/shared/src/types/preferences.ts @@ -0,0 +1,15 @@ +/** + * The 3 values a profile's theme preference can take — `SYSTEM` means "no + * explicit choice, follow the OS/browser preference" (see `apps/web`'s + * `ThemeContext`, which maps this to *not* setting the `data-theme` + * attribute at all, letting `_theme.scss`'s `prefers-color-scheme` media + * query take over). + */ +export const THEME_PREFERENCES = ["LIGHT", "DARK", "SYSTEM"] as const; +/** Inferred TS type for one {@link THEME_PREFERENCES} member. */ +export type ThemePreference = (typeof THEME_PREFERENCES)[number]; + +/** A profile's personalization preferences, as returned by `GET /preferences` / `PATCH /preferences`. */ +export interface PreferencesView { + theme: ThemePreference; +} From 3acde696f51e0242e50d996f08a6ce96d471b94e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 15:55:45 +0200 Subject: [PATCH 2/6] =?UTF-8?q?API:=20module=20preferences=20=E2=80=94=20G?= =?UTF-8?q?ET/PATCH=20/preferences=20(step=202/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPreferences: SYSTEM par défaut si aucune ligne (même logique que dietId/allergies : absent = valeur par défaut, pas une omission) - updatePreferences: upsert (crée la ligne au premier PATCH) - Tests Mocha + Cucumber : 401, valeur invalide, défaut, création à la volée, cloisonnement entre profils --- apps/api/features/preferences.feature | 23 +++++ .../step-definitions/preferences.steps.ts | 15 +++ apps/api/src/app.ts | 2 + .../modules/preferences/preferences.routes.ts | 27 +++++ .../preferences/preferences.service.ts | 31 ++++++ apps/api/test-support/reset-db.ts | 2 +- apps/api/test/preferences.test.ts | 98 +++++++++++++++++++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 apps/api/features/preferences.feature create mode 100644 apps/api/features/step-definitions/preferences.steps.ts create mode 100644 apps/api/src/modules/preferences/preferences.routes.ts create mode 100644 apps/api/src/modules/preferences/preferences.service.ts create mode 100644 apps/api/test/preferences.test.ts diff --git a/apps/api/features/preferences.feature b/apps/api/features/preferences.feature new file mode 100644 index 0000000..17cd1b9 --- /dev/null +++ b/apps/api/features/preferences.feature @@ -0,0 +1,23 @@ +Feature: User preferences (theme) + As a signed-in user + I want to choose a light, dark, or system theme + So that the app matches how I like to read it + + Scenario: A visitor without a session cannot read preferences + When I send a GET request to "/preferences" + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user with no preferences yet defaults to SYSTEM + 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 my preferences + Then the response status should be 200 + And my theme preference should be "SYSTEM" + + Scenario: A signed-in user sets their theme preference + 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 set my theme preference to "DARK" + Then the response status should be 200 + And my theme preference should be "DARK" diff --git a/apps/api/features/step-definitions/preferences.steps.ts b/apps/api/features/step-definitions/preferences.steps.ts new file mode 100644 index 0000000..3062682 --- /dev/null +++ b/apps/api/features/step-definitions/preferences.steps.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { Then, When } from "@cucumber/cucumber"; +import type { CustomWorld } from "../support/world.js"; + +When("I request my preferences", async function (this: CustomWorld) { + this.response = await this.agent.get("/preferences"); +}); + +When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) { + this.response = await this.agent.patch("/preferences").send({ theme }); +}); + +Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) { + assert.equal(this.response.body.theme, theme); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c4f8727..b86dc5d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; +import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; @@ -28,6 +29,7 @@ export function createServer(): ExpressServer { server.mountRouter("/auth", authRouter); server.mountRouter("/house", houseRouter); server.mountRouter("/planning", planningRouter); + server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/profile", profileRouter); server.mountRouter("/reference", referenceRouter); diff --git a/apps/api/src/modules/preferences/preferences.routes.ts b/apps/api/src/modules/preferences/preferences.routes.ts new file mode 100644 index 0000000..67b0cc9 --- /dev/null +++ b/apps/api/src/modules/preferences/preferences.routes.ts @@ -0,0 +1,27 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { updatePreferencesSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getPreferences, updatePreferences } from "./preferences.service.js"; + +/** Router mounted at `/preferences` in app.ts. Every route requires a session — this is the authenticated user's own preferences. */ +export const preferencesRouter = Router(); + +preferencesRouter.get( + "/", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const preferences = await getPreferences(res.locals.userProfile.id); + res.status(200).json(preferences); + }), +); + +preferencesRouter.patch( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updatePreferencesSchema.parse(req.body); + const preferences = await updatePreferences(res.locals.userProfile.id, input.theme); + res.status(200).json(preferences); + }), +); diff --git a/apps/api/src/modules/preferences/preferences.service.ts b/apps/api/src/modules/preferences/preferences.service.ts new file mode 100644 index 0000000..6e6d2a0 --- /dev/null +++ b/apps/api/src/modules/preferences/preferences.service.ts @@ -0,0 +1,31 @@ +import type { PreferencesView, ThemePreference } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** + * A profile's personalization preferences. `SYSTEM` (the schema default) + * is returned both when a row already says so *and* when there's no row + * yet at all — same "absent means the default" philosophy as + * `dietId`/allergies elsewhere in `profile.service.ts`, no row is created + * just to read it. + */ +export async function getPreferences(userProfileId: number): Promise { + const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } }); + return { theme: preferences?.theme ?? "SYSTEM" }; +} + +/** + * Sets a profile's theme preference, creating its preferences row on first + * write (an `upsert` rather than requiring a separate "create" step — a + * profile never needs to explicitly initialize this row before using it). + */ +export async function updatePreferences( + userProfileId: number, + theme: ThemePreference, +): Promise { + const preferences = await prisma.userPreference.upsert({ + where: { userProfileId }, + create: { userProfileId, theme }, + update: { theme }, + }); + return { theme: preferences.theme }; +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 9b5b54e..c758f49 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -10,7 +10,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; export async function resetDatabase() { await prisma.$executeRawUnsafe(` TRUNCATE TABLE - "user_profile_allergy", "allergy", "category", + "user_profile_allergy", "user_preference", "allergy", "category", "planning_item", "planning", "recipe_ingredient", "step", "tech_step_mapping", "tech_step", "recipe", "ingredients", "sources", diff --git a/apps/api/test/preferences.test.ts b/apps/api/test/preferences.test.ts new file mode 100644 index 0000000..959da81 --- /dev/null +++ b/apps/api/test/preferences.test.ts @@ -0,0 +1,98 @@ +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"; + +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("Preferences", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /preferences", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/preferences"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("defaults to SYSTEM when the profile has no preferences row yet", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/preferences"); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ theme: "SYSTEM" }); + }); + }); + + describe("PATCH /preferences", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).patch("/preferences").send({ theme: "DARK" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects an unknown theme with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.patch("/preferences").send({ theme: "PURPLE" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("creates the preferences row on first write, and reuses it on later reads/writes", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const patchRes = await agent.patch("/preferences").send({ theme: "DARK" }); + expect(patchRes.status).to.equal(200); + expect(patchRes.body).to.deep.equal({ theme: "DARK" }); + + const getRes = await agent.get("/preferences"); + expect(getRes.body).to.deep.equal({ theme: "DARK" }); + + const secondPatchRes = await agent.patch("/preferences").send({ theme: "LIGHT" }); + expect(secondPatchRes.body).to.deep.equal({ theme: "LIGHT" }); + + const secondGetRes = await agent.get("/preferences"); + expect(secondGetRes.body).to.deep.equal({ theme: "LIGHT" }); + }); + + it("scopes preferences to the caller's own profile", async () => { + const aliceAgent = request.agent(app); + await aliceAgent.post("/auth/signup").send(buildSignupPayload()); + await aliceAgent.patch("/preferences").send({ theme: "DARK" }); + + const bobAgent = request.agent(app); + await bobAgent.post("/auth/signup").send(buildSignupPayload()); + + const res = await bobAgent.get("/preferences"); + expect(res.body).to.deep.equal({ theme: "SYSTEM" }); + }); + }); +}); From 4bdc9c04707ec926baa8bb04982c492183fa0882 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 15:56:12 +0200 Subject: [PATCH 3/6] =?UTF-8?q?Web:=20UserPreferencesPage=20(th=C3=A8me)?= =?UTF-8?q?=20+=20renommage=20"Pr=C3=A9f=C3=A9rences=20alimentaires"=20(st?= =?UTF-8?q?ep=203/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeContext (features/theme/) : charge/applique le thème du profil connecté via l'attribut data-theme (SYSTEM = pas d'attribut, laisse la media query prefers-color-scheme décider) ; échec réseau non bloquant (worst case reste au thème courant, pas d'unhandled rejection) - UserPreferencesPage, routée /parametres/preferences-utilisateur, hot-save (3 boutons radio Clair/Sombre/Système) - layout.settings.nav.preferences renommé "Préférences alimentaires" (évite la confusion avec ce nouveau concept plus large), nouvelle entrée "Préférences utilisateur" - _theme.scss: commentaires mis à jour (l'attribut data-theme est désormais réellement posé, plus une simple anticipation) --- apps/web/src/App.tsx | 14 +-- apps/web/src/api/client.ts | 12 +++ apps/web/src/features/theme/ThemeContext.tsx | 89 +++++++++++++++++++ apps/web/src/layouts/AppLayout.tsx | 6 +- apps/web/src/locales/fr/translation.json | 14 ++- apps/web/src/main.tsx | 5 +- .../pages/settings/UserPreferencesPage.tsx | 66 ++++++++++++++ .../src/pages/settings/settings-pages.scss | 23 +++++ apps/web/src/styles/_theme.scss | 11 +-- 9 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/theme/ThemeContext.tsx create mode 100644 apps/web/src/pages/settings/UserPreferencesPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4abe841..eda674e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage"; +import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -22,12 +23,12 @@ import { PreferencesPage } from "./pages/settings/PreferencesPage"; * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. * - * `/parametres/*` (compte/préférences/foyer) are the settings pages, - * reachable from the sidebar's bottom "Paramètres" menu and the account - * menu (see `AppLayout`) — nested under `AppLayout` like every other - * authenticated section. `/foyer` is the old, pre-split combined page's - * path; it now just redirects to `/parametres/foyer` so an existing - * bookmark/link keeps working. + * `/parametres/*` (compte/préférences alimentaires/foyer/préférences + * utilisateur) are the settings pages, reachable from the sidebar's bottom + * "Paramètres" menu and the account menu (see `AppLayout`) — nested under + * `AppLayout` like every other authenticated section. `/foyer` is the old, + * pre-split combined page's path; it now just redirects to + * `/parametres/foyer` so an existing bookmark/link keeps working. * * `/onboarding/*` (regime/foyer/allergens, in that order) is also * `RequireAuth`-gated — reached right after signup, once a session already @@ -51,6 +52,7 @@ export function App() { } /> } /> } /> + } /> } /> { + return this.request("/preferences"); + } + + /** Sets the current user's theme preference. */ + public updatePreferences(theme: ThemePreference): Promise { + return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) }); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/features/theme/ThemeContext.tsx b/apps/web/src/features/theme/ThemeContext.tsx new file mode 100644 index 0000000..f8c1021 --- /dev/null +++ b/apps/web/src/features/theme/ThemeContext.tsx @@ -0,0 +1,89 @@ +import type { ThemePreference } from "@batch-cooking/shared"; +import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react"; +import { apiClient } from "../../api/client"; +import { useAuth } from "../auth/AuthContext"; + +/** Shape of the theme state/actions exposed via {@link useTheme}. */ +interface ThemeContextValue { + /** The signed-in user's theme preference — `"SYSTEM"` (the default) for a logged-out visitor too, nothing to load a preference for. */ + theme: ThemePreference; + /** Persists `theme` and applies it immediately. Throws `ApiError` on failure. */ + setTheme: (theme: ThemePreference) => Promise; +} + +/** React context carrying {@link ThemeContextValue} — always accessed through {@link useTheme}, never directly. */ +const ThemeContext = createContext(null); + +/** + * Applies `theme` to the document. `SYSTEM` *removes* the override rather + * than setting a literal `"system"` value `_theme.scss` wouldn't recognize + * — with no `data-theme` attribute at all, its `prefers-color-scheme` + * media query decides instead, exactly the "system" behavior. + */ +function applyTheme(theme: ThemePreference) { + if (theme === "SYSTEM") { + delete document.documentElement.dataset.theme; + } else { + document.documentElement.dataset.theme = theme.toLowerCase(); + } +} + +/** + * Provides the signed-in user's theme preference and keeps the document in + * sync with it. Must be nested inside `AuthProvider` (reads `useAuth()`'s + * `user` to know when a profile is available to load a preference for) — + * see `main.tsx`. + * + * Keyed on `user?.id` rather than `user` itself: `AuthContext`'s `user` + * object is a fresh reference every time it refreshes (e.g. after an + * unrelated `refreshUser()` call elsewhere), which shouldn't re-trigger a + * preferences fetch — only actually signing in/out or switching accounts + * should. + */ +export function ThemeProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [theme, setThemeState] = useState("SYSTEM"); + + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, see the doc comment above. + useEffect(() => { + if (!user) { + setThemeState("SYSTEM"); + applyTheme("SYSTEM"); + return; + } + + let cancelled = false; + apiClient + .getPreferences() + .then((preferences) => { + if (cancelled) return; + setThemeState(preferences.theme); + applyTheme(preferences.theme); + }) + // Failing to load a preference (e.g. a network hiccup) shouldn't break + // the rest of the app — worst case the theme just stays at its + // current/default value, same "not fatal" spirit as AuthContext's own + // `me()` call on mount. + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [user?.id]); + + const setTheme = useCallback(async (newTheme: ThemePreference) => { + await apiClient.updatePreferences(newTheme); + setThemeState(newTheme); + applyTheme(newTheme); + }, []); + + return {children}; +} + +/** Reads the current theme state/actions. Must be called from within a {@link ThemeProvider}. */ +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx; +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index eb58f9a..0033027 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -23,6 +23,7 @@ const SETTINGS_ITEMS = [ { to: "/parametres/compte", key: "account" }, { to: "/parametres/preferences", key: "preferences" }, { to: "/parametres/foyer", key: "household" }, + { to: "/parametres/preferences-utilisateur", key: "userPreferences" }, ] as const; /** @@ -71,8 +72,9 @@ export function AppLayout() { } /** - * Collapsible "Paramètres" section revealing the three settings pages - * (Compte/Préférences/Foyer — see `pages/settings/`). Starts open whenever + * Collapsible "Paramètres" section revealing the settings pages (Compte / + * Préférences alimentaires / Foyer / Préférences utilisateur — see + * `pages/settings/`). Starts open whenever * the current route is already under `/parametres`, so following a link * there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu; * otherwise starts closed to keep the sidebar's main focus on the primary diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 35a1e30..ff633b0 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -68,8 +68,9 @@ "toggle": "Paramètres", "nav": { "account": "Compte", - "preferences": "Préférences", - "household": "Foyer" + "preferences": "Préférences alimentaires", + "household": "Foyer", + "userPreferences": "Préférences utilisateur" } }, "accountMenu": { @@ -145,6 +146,15 @@ "intolerancesLabel": "Intolérances" } }, + "userPreferences": { + "title": "Préférences utilisateur", + "themeLabel": "Thème", + "theme": { + "LIGHT": "Clair", + "DARK": "Sombre", + "SYSTEM": "Système" + } + }, "household": { "title": "Foyer", "form": { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7416923..8f16472 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { AuthProvider } from "./features/auth/AuthContext"; +import { ThemeProvider } from "./features/theme/ThemeContext"; // Side-effect import: initializes the i18next instance before anything // renders (react-i18next reads it via context under the hood). See i18n/i18n.ts. import "./i18n/i18n"; @@ -19,7 +20,9 @@ createRoot(rootElement).render( - + + + , diff --git a/apps/web/src/pages/settings/UserPreferencesPage.tsx b/apps/web/src/pages/settings/UserPreferencesPage.tsx new file mode 100644 index 0000000..5efb2a7 --- /dev/null +++ b/apps/web/src/pages/settings/UserPreferencesPage.tsx @@ -0,0 +1,66 @@ +import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooking/shared"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ApiError } from "../../api/client"; +import { useTheme } from "../../features/theme/ThemeContext"; +import { errorMessageService } from "../../services/error-message.service"; +import "./settings-pages.scss"; + +/** Status of the theme choice's own autosave — see `PreferencesPage` for the same hot-saving pattern. */ +type SaveState = "idle" | "saving" | "saved" | "error"; + +/** + * User (personalization) preferences — routed at + * `/parametres/preferences-utilisateur`. Just the theme choice for now + * (light/dark/system — see `features/theme/ThemeContext.tsx`), meant to + * grow. Distinct from `/parametres/preferences` ("Préférences + * alimentaires" — regime/allergies): that page is about the household's + * food constraints, this one is about how the app itself looks, unrelated + * concerns that happened to share a name before this page existed. + */ +export function UserPreferencesPage() { + const { t } = useTranslation(); + const { theme, setTheme } = useTheme(); + const [saveState, setSaveState] = useState("idle"); + const [saveError, setSaveError] = useState(null); + + async function handleChange(newTheme: ThemePreference) { + setSaveState("saving"); + try { + await setTheme(newTheme); + setSaveState("saved"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setSaveError(errorMessageService.getLabel(code)); + setSaveState("error"); + } + } + + return ( +
+

{t("userPreferences.title")}

+ +
+
+ {t("userPreferences.themeLabel")} + {THEME_PREFERENCES.map((option) => ( + + ))} +
+ + {saveState === "saving" &&

{t("common.saving")}

} + {saveState === "saved" &&

{t("common.saved")}

} + {saveState === "error" &&

{saveError}

} +
+
+ ); +} diff --git a/apps/web/src/pages/settings/settings-pages.scss b/apps/web/src/pages/settings/settings-pages.scss index 1338858..9eebd4c 100644 --- a/apps/web/src/pages/settings/settings-pages.scss +++ b/apps/web/src/pages/settings/settings-pages.scss @@ -149,3 +149,26 @@ button.settings-page__link-button { cursor: pointer; text-decoration: underline; } + +// Theme choice (UserPreferencesPage) — a plain radio group, no fieldset/ +// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy- +// select` in profile-forms.scss is checkbox-grid specific). +.theme-select { + border: none; + padding: 0; + margin: 0; + + legend { + padding: 0 0 var(--space-sm); + font-weight: 600; + font-size: var(--font-size-sm); + } + + &__option { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) 0; + cursor: pointer; + } +} diff --git a/apps/web/src/styles/_theme.scss b/apps/web/src/styles/_theme.scss index b062d73..1afa0c3 100644 --- a/apps/web/src/styles/_theme.scss +++ b/apps/web/src/styles/_theme.scss @@ -107,9 +107,9 @@ // --- Dark mode --------------------------------------------------------- // Follows the OS/browser preference by default. Guarded with -// `:root:not([data-theme="light"])` so that, if a manual theme switch is -// ever added, an explicit "light" choice can override a dark OS setting — -// today nothing sets `data-theme`, so this simply tracks system preference. +// `:root:not([data-theme="light"])` so an explicit "light" choice (see +// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all — +// this block then decides) can override a dark OS setting. @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --color-background: #14181a; @@ -138,8 +138,9 @@ } } -// Mirrors the block above for a future explicit "dark" choice (e.g. a -// theme toggle), so it wins over the OS setting in both directions. +// Mirrors the block above for an explicit "dark" choice (`ThemeContext` +// sets `data-theme="dark"` on ``), so it wins over the OS setting in +// both directions. :root[data-theme="dark"] { --color-background: #14181a; --color-surface: #1c221e; From a8d897dfab1e8261b2d6ab2992ddd8ace00a7c46 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 15:56:36 +0200 Subject: [PATCH 4/6] =?UTF-8?q?Tests:=20couverture=20Cypress=20pour=20les?= =?UTF-8?q?=20pr=C3=A9f=C3=A9rences=20utilisateur=20(step=204/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user-preferences.cy.ts: défaut SYSTEM, thème sauvegardé restitué et appliqué au document, autosave sans bouton "Enregistrer" - sidebar.cy.ts: 4 pages de paramètres (libellé "Préférences alimentaires" renommé + nouvelle entrée "Préférences utilisateur") - auth.cy.ts/onboarding.cy.ts: corrige un intercept **/planning/current périmé (route supprimée dans la PR précédente) — masqué jusqu'ici car aucune assertion n'en dépendait directement, mais provoquait un appel réseau non mocké --- apps/web/cypress/e2e/auth.cy.ts | 6 +-- apps/web/cypress/e2e/onboarding.cy.ts | 2 +- apps/web/cypress/e2e/sidebar.cy.ts | 17 +++++-- apps/web/cypress/e2e/user-preferences.cy.ts | 54 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 apps/web/cypress/e2e/user-preferences.cy.ts diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts index 9be4fde..e0baebd 100644 --- a/apps/web/cypress/e2e/auth.cy.ts +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -76,7 +76,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("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/login", { statusCode: 200, body: { @@ -130,7 +130,7 @@ describe("Already authenticated", () => { dietId: null, }, }); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/login"); cy.url().should("not.include", "/login"); @@ -150,7 +150,7 @@ describe("Already authenticated", () => { dietId: null, }, }); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); cy.visit("/"); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts index 8906290..c8cf368 100644 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ b/apps/web/cypress/e2e/onboarding.cy.ts @@ -16,7 +16,7 @@ const signupResponse = { function signupAndReachOnboarding() { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); cy.visit("/signup"); cy.get("#firstName").type("Alice"); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts index 07295d2..a7d20ab 100644 --- a/apps/web/cypress/e2e/sidebar.cy.ts +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -13,7 +13,9 @@ const authenticatedProfile = { describe("Sidebar — settings menu and account menu", () => { beforeEach(() => { cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + // Not "**/planning*" — that glob also matches the Vite dev request for + // planning-page.scss (see planning-page.cy.ts for the same gotcha). + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); }); it("no longer lists Foyer in the main nav", () => { @@ -21,15 +23,24 @@ describe("Sidebar — settings menu and account menu", () => { cy.get(".app-sidebar__nav a").should("not.contain", "Foyer"); }); - it("reveals the three settings pages behind the Paramètres toggle", () => { + it("reveals the four settings pages behind the Paramètres toggle", () => { cy.visit("/"); cy.contains("a", "Compte").should("not.exist"); cy.contains("button", "Paramètres").click(); cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte"); - cy.contains("a", "Préférences").should("have.attr", "href", "/parametres/preferences"); + cy.contains("a", "Préférences alimentaires").should( + "have.attr", + "href", + "/parametres/preferences", + ); cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer"); + cy.contains("a", "Préférences utilisateur").should( + "have.attr", + "href", + "/parametres/preferences-utilisateur", + ); }); it("opens the account menu from the greeting and links to Mon compte", () => { diff --git a/apps/web/cypress/e2e/user-preferences.cy.ts b/apps/web/cypress/e2e/user-preferences.cy.ts new file mode 100644 index 0000000..0c09b1c --- /dev/null +++ b/apps/web/cypress/e2e/user-preferences.cy.ts @@ -0,0 +1,54 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, +}; + +describe("User preferences (/parametres/preferences-utilisateur)", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("shows SYSTEM selected by default", () => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } }); + + cy.visit("/parametres/preferences-utilisateur"); + + cy.contains("label", "Système").find("input[type=radio]").should("be.checked"); + cy.contains("label", "Clair").find("input[type=radio]").should("not.be.checked"); + cy.contains("label", "Sombre").find("input[type=radio]").should("not.be.checked"); + // SYSTEM never sets an override — the OS/browser preference decides. + cy.get("html").should("not.have.attr", "data-theme"); + }); + + it("shows the previously saved theme selected, and applies it to the document", () => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } }); + + cy.visit("/parametres/preferences-utilisateur"); + + cy.contains("label", "Sombre").find("input[type=radio]").should("be.checked"); + cy.get("html").should("have.attr", "data-theme", "dark"); + }); + + it("switching theme autosaves and applies immediately, no explicit save button", () => { + cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "SYSTEM" } }); + cy.intercept("PATCH", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } }).as( + "updatePreferences", + ); + + cy.visit("/parametres/preferences-utilisateur"); + cy.contains("button", "Enregistrer").should("not.exist"); + + cy.contains("label", "Clair").click(); + + cy.wait("@updatePreferences").its("request.body").should("deep.equal", { theme: "LIGHT" }); + cy.contains("Enregistré ✓").should("be.visible"); + cy.get("html").should("have.attr", "data-theme", "light"); + }); +}); From 73bfcd8facc616251eaa63215cf01fc06cfb0ebe Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 16:16:42 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Web:=20sidebar=20avec=20ic=C3=B4nes=20+=20r?= =?UTF-8?q?epli/d=C3=A9ploiement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design validé via une maquette HTML itérée avec l'utilisateur (voir historique de conversation) avant implémentation. - nav-icons.tsx: jeu d'icônes trait fin (24x24, inline SVG) pour les 3 items de nav principaux, les 4 pages de Paramètres et le chevron de repli — pas de librairie d'icônes pour une poignée de glyphes - AppLayout: bouton replier/déplier à côté du logo — la sidebar passe de 15rem à un rail 4.25rem icônes-seules ; un seul toggle de classe CSS sur le