diff --git a/README.md b/README.md
index e90929c..6a2261d 100644
--- a/README.md
+++ b/README.md
@@ -281,7 +281,13 @@ parente, pourquoi un composant stub partagé) :
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
grille de cases à cocher dans un `
`/`` plutôt qu'un
- `` — bien plus repérable/tapable, notamment sur mobile.
+ `` — bien plus repérable/tapable, notamment sur mobile. Prend un
+ `legend` en prop (pas un libellé fixe interne) : le même composant est rendu
+ **deux fois** par chaque page consommatrice — une fois pour les allergies
+ (`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
+ (`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
+ `GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
+ liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
@@ -289,9 +295,16 @@ parente, pourquoi un composant stub partagé) :
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
-- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes trois réglages,
- modifiables à tout moment, chaque section (foyer/régime/allergènes) avec son
- propre bouton "Enregistrer" (3 ressources API indépendantes).
+- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
+ à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
+ chaque section sauvegarde automatiquement peu après la dernière modification —
+ nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
+ pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
+ immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
+ handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
+ observerait la valeur — un tel effect se déclencherait aussi au chargement
+ initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
+ "vient d'être chargé" de "vient d'être modifié par l'utilisateur".
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
diff --git a/apps/web/cypress/e2e/household.cy.ts b/apps/web/cypress/e2e/household.cy.ts
index 93fba34..9255797 100644
--- a/apps/web/cypress/e2e/household.cy.ts
+++ b/apps/web/cypress/e2e/household.cy.ts
@@ -10,7 +10,7 @@ const authenticatedProfile = {
dietId: 2,
};
-describe("Household & profile settings (/foyer)", () => {
+describe("Household & profile settings (/foyer) — hot saving", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/house/current", {
@@ -27,23 +27,30 @@ describe("Household & profile settings (/foyer)", () => {
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
- { id: 1, name: "Arachides" },
- { id: 2, name: "Gluten" },
+ { id: 1, name: "Arachides", kind: "ALLERGY" },
+ { id: 2, name: "Gluten", kind: "INTOLERANCE" },
],
});
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
});
- it("loads the current household name, regime and allergens", () => {
+ it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => {
cy.visit("/foyer");
cy.get("#houseName").should("have.value", "Chez Alice");
cy.get("#diet").should("have.value", "2");
+ cy.contains("legend", "Allergies").should("be.visible");
+ cy.contains("legend", "Intolérances").should("be.visible");
cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked");
cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked");
});
- it("saves the household name independently of the other sections", () => {
+ it("has no explicit save button anywhere on the page", () => {
+ cy.visit("/foyer");
+ cy.contains("button", "Enregistrer").should("not.exist");
+ });
+
+ it("autosaves the household name a short pause after typing, no button click", () => {
cy.intercept("PATCH", "**/house/current", {
statusCode: 200,
body: { id: 1, name: "Chez les Martin" },
@@ -52,15 +59,22 @@ describe("Household & profile settings (/foyer)", () => {
cy.visit("/foyer");
cy.get("#houseName").clear();
cy.get("#houseName").type("Chez les Martin");
- cy.get("#houseName")
- .closest("form")
- .within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" });
- cy.get("#houseName").closest("form").contains("Enregistré ✓").should("be.visible");
+ cy.contains("Enregistré ✓").should("be.visible");
});
- it("saves the regime independently of the other sections", () => {
+ it("does not autosave an empty household name — shows a validation message instead", () => {
+ cy.intercept("PATCH", "**/house/current").as("renameHouse");
+
+ cy.visit("/foyer");
+ cy.get("#houseName").clear();
+
+ cy.contains("Le nom du foyer est requis").should("be.visible");
+ cy.get("@renameHouse.all").should("have.length", 0);
+ });
+
+ it("autosaves the regime as soon as it's selected", () => {
cy.intercept("PATCH", "**/profile/diet", {
statusCode: 200,
body: { ...authenticatedProfile, dietId: 1 },
@@ -68,24 +82,17 @@ describe("Household & profile settings (/foyer)", () => {
cy.visit("/foyer");
cy.get("#diet").select("Omnivore");
- cy.get("#diet")
- .closest("form")
- .within(() => cy.contains("button", "Enregistrer").click());
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 });
- cy.get("#diet").closest("form").contains("Enregistré ✓").should("be.visible");
});
- it("saves the allergen selection independently of the other sections", () => {
- cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1, 2] }).as(
+ it("autosaves allergies and intolerances together after checking boxes", () => {
+ cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as(
"updateAllergies",
);
cy.visit("/foyer");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
- cy.contains("fieldset", "Allergies").within(() => {
- cy.contains("button", "Enregistrer").click();
- });
cy.wait("@updateAllergies")
.its("request.body")
diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts
index adce3f4..39dbd0f 100644
--- a/apps/web/cypress/e2e/onboarding.cy.ts
+++ b/apps/web/cypress/e2e/onboarding.cy.ts
@@ -38,8 +38,8 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
cy.intercept("GET", "**/reference/allergies", {
statusCode: 200,
body: [
- { id: 1, name: "Arachides" },
- { id: 2, name: "Gluten" },
+ { id: 1, name: "Arachides", kind: "ALLERGY" },
+ { id: 2, name: "Gluten", kind: "INTOLERANCE" },
],
});
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
@@ -71,9 +71,11 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
cy.contains("button", "Continuer").click();
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
- // Step 3/3 — allergens/intolerances, then finish.
+ // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish.
cy.url().should("include", "/onboarding/allergenes");
cy.contains("Étape 3 sur 3").should("be.visible");
+ cy.contains("legend", "Allergies").should("be.visible");
+ cy.contains("legend", "Intolérances").should("be.visible");
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
cy.contains("button", "Terminer").click();
cy.wait("@updateAllergies")
diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx
index ee27554..d271ac3 100644
--- a/apps/web/src/features/profile/AllergySelect.tsx
+++ b/apps/web/src/features/profile/AllergySelect.tsx
@@ -1,8 +1,8 @@
import type { AllergyView } from "@batch-cooking/shared";
-import { useTranslation } from "react-i18next";
import "./profile-forms.scss";
interface AllergySelectProps {
+ legend: string;
allergies: AllergyView[];
value: number[];
onChange: (allergyIds: number[]) => void;
@@ -11,26 +11,30 @@ interface AllergySelectProps {
/**
* Multi-select (checkbox grid, not a native `` — far more
* discoverable/tappable, especially on the mobile viewport this app is
- * eventually embedded into via Capacitor) for allergens/intolerances. Used
+ * eventually embedded into via Capacitor) for a group of allergens. Used
* both by the signup wizard's allergens step and the `/foyer` settings
- * page. An empty `value` is a normal, valid state (no declared allergies,
- * or this skippable step was skipped), not an incomplete one.
+ * page, and rendered *twice* by each — once for allergies, once for
+ * intolerances (`AllergyView.kind` groups them; callers filter and pass
+ * two separate lists rather than this component knowing about the split).
+ * An empty `value` is a normal, valid state (no declared allergies, or
+ * this skippable step was skipped), not an incomplete one.
*
- * A ``/`` (not a bare ``, which only associates
- * with a single control) — the correct semantic label for a group of
- * checkboxes. Receives `allergies` as a prop rather than fetching them
- * itself — same rationale as `DietSelect`.
+ * `legend` (not a fixed internal label) — the same component serves both
+ * groups, only the heading differs. A ``/`` (not a bare
+ * ``, which only associates with a single control) is the correct
+ * semantic label for a group of checkboxes.
+ *
+ * Receives `allergies` as a prop rather than fetching them itself — same
+ * rationale as `DietSelect`.
*/
-export function AllergySelect({ allergies, value, onChange }: AllergySelectProps) {
- const { t } = useTranslation();
-
+export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) {
function toggle(id: number) {
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
}
return (
- {t("household.form.allergiesLabel")}
+ {legend}
{allergies.map((allergy) => (
>({});
const [houseSaveState, setHouseSaveState] = useState("idle");
const [houseSaveError, setHouseSaveError] = useState(null);
+ const houseNameTimeout = useRef(undefined);
const [diets, setDiets] = useState([]);
const [dietId, setDietId] = useState(null);
@@ -48,6 +60,7 @@ export function HouseholdPage() {
const [allergyIds, setAllergyIds] = useState([]);
const [allergySaveState, setAllergySaveState] = useState("idle");
const [allergySaveError, setAllergySaveError] = useState(null);
+ const allergiesTimeout = useRef(undefined);
useEffect(() => {
let cancelled = false;
@@ -82,30 +95,44 @@ export function HouseholdPage() {
};
}, []);
- async function handleSaveHouseName(e: FormEvent) {
- e.preventDefault();
- const result = renameHouseSchema.safeParse({ name: houseName });
+ // Pending debounced saves must not fire after unmount (e.g. the user
+ // navigates away mid-debounce).
+ useEffect(() => {
+ return () => {
+ window.clearTimeout(houseNameTimeout.current);
+ window.clearTimeout(allergiesTimeout.current);
+ };
+ }, []);
+
+ function handleHouseNameChange(name: string) {
+ setHouseName(name);
+ window.clearTimeout(houseNameTimeout.current);
+
+ const result = renameHouseSchema.safeParse({ name });
if (!result.success) {
setHouseNameErrors(fieldErrorsFrom(result.error));
+ setHouseSaveState("idle");
return;
}
setHouseNameErrors({});
setHouseSaveState("saving");
- try {
- await apiClient.renameHouse(result.data.name);
- setHouseSaveState("saved");
- } catch (err) {
- const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
- setHouseSaveError(errorMessageService.getLabel(code));
- setHouseSaveState("error");
- }
+ houseNameTimeout.current = window.setTimeout(async () => {
+ try {
+ await apiClient.renameHouse(result.data.name);
+ setHouseSaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setHouseSaveError(errorMessageService.getLabel(code));
+ setHouseSaveState("error");
+ }
+ }, HOUSE_NAME_DEBOUNCE_MS);
}
- async function handleSaveDiet(e: FormEvent) {
- e.preventDefault();
+ async function handleDietChange(newDietId: number | null) {
+ setDietId(newDietId);
setDietSaveState("saving");
try {
- await apiClient.updateDiet(dietId);
+ await apiClient.updateDiet(newDietId);
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
// today, but the sidebar/anywhere else that might in the future
// shouldn't have to know this page exists to stay correct.
@@ -118,17 +145,20 @@ export function HouseholdPage() {
}
}
- async function handleSaveAllergies(e: FormEvent) {
- e.preventDefault();
+ function handleAllergyIdsChange(newAllergyIds: number[]) {
+ setAllergyIds(newAllergyIds);
+ window.clearTimeout(allergiesTimeout.current);
setAllergySaveState("saving");
- try {
- await apiClient.updateAllergyIds(allergyIds);
- setAllergySaveState("saved");
- } catch (err) {
- const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
- setAllergySaveError(errorMessageService.getLabel(code));
- setAllergySaveState("error");
- }
+ allergiesTimeout.current = window.setTimeout(async () => {
+ try {
+ await apiClient.updateAllergyIds(newAllergyIds);
+ setAllergySaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setAllergySaveError(errorMessageService.getLabel(code));
+ setAllergySaveState("error");
+ }
+ }, ALLERGIES_DEBOUNCE_MS);
}
if (isLoading) {
@@ -153,38 +183,57 @@ export function HouseholdPage() {
{t("household.title")}
-
+
+
+
+
-
+
+
+
+
-
+
+
allergy.kind === "ALLERGY")}
+ value={allergyIds}
+ onChange={handleAllergyIdsChange}
+ />
+ allergy.kind === "INTOLERANCE")}
+ value={allergyIds}
+ onChange={handleAllergyIdsChange}
+ />
+
+
);
}
+
+/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section — `idle` renders nothing. */
+function SaveStatus({
+ state,
+ error,
+ t,
+}: {
+ state: SaveState;
+ error: string | null;
+ t: (key: string) => string;
+}) {
+ if (state === "saving") {
+ return {t("household.form.saving")}
;
+ }
+ if (state === "saved") {
+ return {t("household.form.saved")}
;
+ }
+ if (state === "error") {
+ return {error}
;
+ }
+ return null;
+}
diff --git a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
index ff433a2..bb6a1d4 100644
--- a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
+++ b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
@@ -66,7 +66,20 @@ export function OnboardingAllergensPage() {
{isLoading ? (
{t("onboarding.loading")}
) : (
-
+ <>
+ allergy.kind === "ALLERGY")}
+ value={allergyIds}
+ onChange={setAllergyIds}
+ />
+ allergy.kind === "INTOLERANCE")}
+ value={allergyIds}
+ onChange={setAllergyIds}
+ />
+ >
)}
{formError && {formError}
}
diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md
index 46b51a3..74436e2 100644
--- a/specs/frontend-architecture.md
+++ b/specs/frontend-architecture.md
@@ -154,14 +154,23 @@ flowchart LR
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de
bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip.
- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
- trois réglages, éditables à tout moment. Trois sections, trois boutons
- "Enregistrer" indépendants (3 ressources API distinctes : `PATCH /house/current`,
- `/profile/diet`, `/profile/allergies`).
+ réglages, éditables à tout moment, en **hot saving** (retour fonctionnel : pas de
+ bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière
+ modification (nom du foyer et allergènes/intolérances debouncés — 600ms/500ms —
+ régime immédiat) — 3 ressources API indépendantes (`PATCH /house/current`,
+ `/profile/diet`, `/profile/allergies`), 3 cycles de sauvegarde indépendants.
+ Déclenché depuis le handler `onChange` de chaque champ, jamais un `useEffect`
+ générique sur la valeur — un tel effect se déclencherait aussi au chargement
+ initial (le `GET` peuple le même state), sans distinction propre entre "vient
+ d'être chargé" et "vient d'être modifié".
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
fetcher). `AllergySelect` est un ``/`` + grille de cases à
cocher, pas un `` — bien plus repérable/tapable, notamment sur
- mobile (voir la note Capacitor plus haut).
+ mobile (voir la note Capacitor plus haut). Prend un `legend` en prop : chaque
+ page consommatrice le rend **deux fois** (allergies / intolérances, filtrées
+ côté client via `AllergyView.kind`), mais la sélection reste une seule liste
+ d'IDs partagée entre les deux groupes.
### Deux bugs de state trouvés en testant dans le navigateur