batchCooking/specs/frontend-architecture.md
Nicolas 27bfa3f6ab feat(shopping-list): liste de courses agrégée depuis le planning
GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les
ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par
les portions de chaque créneau (PlanningItem.portions / Recipe.portions),
regroupés par paire (ingredientId, unitId) — jamais null contrairement à
GET /planning, une semaine vide redescend en items: [].

Côté web, ShoppingListPage rend cette liste groupée par rayon (même
IngredientCategory que IngredientPicker), triée alphabétiquement en
français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite
du composant). WeekNavigator (flèches + calendrier) est extrait de
PlanningPage vers features/planning/ pour être partagé entre les deux
pages ; ses libellés migrent de planning.* vers common.weekNav.*/
common.calendar.*/common.days.*, plus génériques pour une page qui n'est
plus seulement le planning.

ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le
dernier stub restant).

Tests : Mocha (agrégation, mise à l'échelle par portions, unités non
fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri,
navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts
pour le nouveau rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

50 KiB
Raw Blame History

Architecture frontend — Projet Batch-cooking

Documentation de l'organisation d'apps/web : structure des dossiers, routing, gestion des erreurs, et conventions de style (SCSS/theming).


Structure des dossiers

apps/web/src/
├── api/
│   └── client.ts            # ApiClient — appels fetch vers l'API (voir error-handling.md)
├── i18n/
│   └── i18n.ts               # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
│   └── fr/translation.json   # libellés français (common.*, errors.*, auth.*, layout.*, planning.*, recipes.*, shoppingList.*, onboarding.*, household.*, account.*, preferences.*, userPreferences.*, credits.*, catalog.*)
├── services/
│   └── error-message.service.ts  # ErrorMessageService — code d'erreur → clé i18next
├── components/
│   └── ui/                   # primitives réutilisables partout, voir plus bas
│       ├── Dialog.tsx + dialog.scss       # modale (élément <dialog> natif)
│       ├── Checkbox.tsx / Radio.tsx        # "carte sélectionnable" (CheckboxOption/RadioOption)
│       └── Tooltip.tsx + tooltip.scss      # infobulle CSS-only
├── features/
│   ├── auth/                # authentification
│   │   ├── AuthContext.tsx       # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount)
│   │   ├── RequireAuth.tsx / RedirectIfAuthenticated.tsx  # gardes de route
│   │   └── auth-form.scss        # styles partagés par LoginPage et SignupPage
│   ├── theme/
│   │   └── ThemeContext.tsx      # clair/sombre/système, voir plus bas
│   ├── profile/              # champs du parcours régime/allergènes/goûts (personnels)
│   │   ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx / DislikedIngredientsField.tsx
│   │   └── profile-forms.scss
│   ├── house/                # préférences propres au foyer
│   │   ├── SourceSelect.tsx      # quelles sources externes le foyer voit
│   │   └── house-forms.scss
│   ├── planning/
│   │   ├── RecipePickerDialog.tsx  # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│   │   ├── recipe-picker-dialog.scss
│   │   └── WeekNavigator.tsx + week-navigator.scss  # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas
│   └── recipes/               # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat
│       ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx   # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous)
│       ├── recipes.scss                                              # feuille de style partagée, importée depuis chaque sous-dossier via ../recipes.scss
│       ├── badges/
│       │   └── DietTagSelect.tsx / DietBadges.tsx / AllergenBadges.tsx / ReproducibleBadge.tsx / FavoriteStarButton.tsx
│       ├── ingredients/
│       │   └── IngredientPicker.tsx / IngredientRow.tsx / ingredient-icons.tsx
│       ├── steps/
│       │   └── StepListEditor.tsx / StepDescription.tsx / highlight-tech-steps.ts
│       └── sources/
│           └── RecipeSourcesPanel.tsx / SourceItemTable.tsx / RecipeImportForm.tsx / recipe-import-draft.ts / useEnabledSources.ts
├── layouts/
│   ├── AppLayout.tsx + .scss  # sidebar (nav, sous-menu Paramètres, menu compte) commune à tout l'espace connecté, voir plus bas
│   └── nav-icons.tsx          # ré-export nommé des icônes lucide-react utilisées par la sidebar
├── pages/                      # un sous-dossier par section routée — jamais tous les fichiers à plat, un seul composant par sous-dossier n'est pas un problème (cohérence de rangement avant tout)
│   ├── auth/
│   │   └── LoginPage.tsx / SignupPage.tsx (via features/auth/auth-form.scss, partagé)
│   ├── planning/
│   │   └── PlanningPage.tsx + planning-page.scss   # grille de la semaine (routée sur "/"), voir plus bas
│   ├── recipes/
│   │   ├── RecipesPage.tsx                      # vue maître-détail du catalogue (routée sur /recettes, /recettes/:id, /recettes/sources/:sourceKey/:externalId)
│   │   ├── RecipeFormPage.tsx                   # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier)
│   │   └── ImportRecipePage.tsx                 # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId)
│   ├── shopping-list/
│   │   ├── ShoppingListPage.tsx + shopping-list-page.scss  # liste agrégée (GET /shopping-list), groupée par rayon, voir plus bas
│   │   └── shopping-list.ts                     # logique pure (groupement/tri/formatage) extraite du composant, voir plus bas
│   ├── settings/                                # ancienne HouseholdPage éclatée en 5 pages, voir plus bas
│   │   ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│   │   ├── UserPreferencesPage.tsx / CreditsPage.tsx
│   │   └── settings-pages.scss
│   └── onboarding/            # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
│       ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
│       └── onboarding.scss
├── styles/
│   ├── _theme.scss          # tokens de design (couleurs, espacements, typographie) — variantes clair/sombre
│   └── global.scss          # reset minimal + import du theme — importé une seule fois (main.tsx)
├── lib/
│   ├── zod-errors.ts        # utilitaire : erreurs zod → { champ: message }
│   └── client-key.ts        # makeClientKey() — identité React locale/éphémère pour une ligne de brouillon (ingrédient/étape en cours d'édition), jamais envoyée au serveur ; volontairement pas crypto.randomUUID() (indisponible hors contexte sécurisé, ex. Capacitor)
├── App.tsx                  # table de routes
└── main.tsx                 # point d'entrée : providers (Router, AuthProvider, ThemeProvider) + imports i18n/CSS globaux

Règle de placement des styles : un style spécifique à un seul composant/page vit dans un fichier .scss au même niveau que ce composant (PlanningPage.tsx + planning-page.scss). Un style partagé par plusieurs composants d'une même feature vit dans le dossier de la feature (features/auth/auth-form.scss, utilisé par LoginPage et SignupPage). Seuls le reset et les tokens globaux vivent dans styles/.


Routing et gardes d'authentification

flowchart TB
  START(("Visite de l'app"))
  CHECK{"AuthProvider :<br/>GET /auth/me"}
  START --> CHECK

  CHECK -->|"200 (session valide)"| AUTHED["user défini"]
  CHECK -->|"401 (pas de session)"| ANON["user = null"]

  AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"]
  LAYOUT --> ROUTE_HOME["/ → PlanningPage"]
  LAYOUT --> ROUTE_RECIPES["/recettes, /recettes/:id,<br/>/recettes/sources/:sourceKey/:externalId → RecipesPage"]
  LAYOUT --> ROUTE_RECIPE_FORM["/recettes/nouvelle,<br/>/recettes/:id/modifier → RecipeFormPage"]
  LAYOUT --> ROUTE_IMPORT["/recettes/importer/:sourceKey/:externalId → ImportRecipePage"]
  LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
  LAYOUT --> ROUTE_SETTINGS["/parametres/* → pages de paramètres"]
  ROUTE_OLD["/foyer"] -->|"redirige"| ROUTE_SETTINGS
  AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
  ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME

  ANON --> ROUTE_HOME_A["/, /recettes, ..."]
  ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
  ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
  • AuthContext (features/auth/AuthContext.tsx) appelle GET /auth/me une seule 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 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.
  • RedirectIfAuthenticated verrouille sa décision une seule fois (au moment où isLoading passe à false), au lieu de réagir à chaque changement de user — bug trouvé en construisant le wizard d'inscription (voir plus bas) : un navigate() explicite dans le formulaire qu'elle protège entrait en course avec son propre <Navigate>, invisible tant que les deux ciblaient "/".
  • Table de routes complète (App.tsx) : sous l'unique parent RequireAuth+AppLayout/ (PlanningPage), /recettes / /recettes/:id / /recettes/sources/:sourceKey/:externalId (les trois rendent le même composant RecipesPage, voir plus bas), /recettes/nouvelle / /recettes/:id/modifier (RecipeFormPage), /recettes/importer/:sourceKey/:externalId (ImportRecipePage, route de secours autonome), /liste-de-courses (ShoppingListPage), et les cinq pages /parametres/* (compte, préférences, foyer, préférences-utilisateur, crédits — voir plus bas). /foyer (l'URL de l'ancienne page combinée) redirige vers /parametres/foyer pour ne pas casser un lien existant.
  • /onboarding/* reste son propre groupe de routes top-level (pas nichées sous AppLayout — wizard plein écran sans sidebar) : regimefoyersources (conditionnelle — seulement si l'étape foyer a créé/rejoint un foyer, sinon on saute directement à l'étape suivante) → allergenes.

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, voir la table complète plus haut). AppLayout (layouts/AppLayout.tsx) rend une 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).

La sidebar a grandi avec l'app :

  • Icônes (layouts/nav-icons.tsx) — ré-export nommé d'icônes lucide-react (remplace un ancien jeu de SVG dessinés à la main) : PlanningIcon, RecipesIcon, ShoppingListIcon, SettingsIcon, AccountIcon, DietPreferencesIcon, HouseholdIcon, UserPreferencesIcon, CreditsIcon, FavoriteIcon, PublicIcon, SourcesIcon, SourceLinkIcon, ChevronLeftIcon. Toujours accompagnée d'un libellé/tooltip, donc aria-hidden="true" est posé par chaque appelant.
  • Structure : bloc marque ("batchCooking" complet, réduit à "bC" en mode replié), nav principale (Planning /, Recettes /recettes, Liste de courses /liste-de-courses), un SettingsMenu repliable, un AccountMenu, un pied de page avec le numéro de version.
  • Rail repliableisCollapsed persisté dans localStorage (batchcooking:sidebarCollapsed) : un simple toggle de classe (.app-sidebar.collapsed) masque les .label en CSS ; chaque item garde son icône et gagne un title en tooltip.
  • SettingsMenu — révèle Compte (/parametres/compte), Préférences (/parametres/preferences), Foyer (/parametres/foyer), Préférences utilisateur (/parametres/preferences-utilisateur), Crédits (/parametres/credits). Ouvert par défaut si la route courante est déjà sous /parametres, replié sinon — indépendant du repli de toute la sidebar.
  • AccountMenu — remplace l'ancien simple "bonjour + déconnexion" : un menu déroulant depuis l'avatar/nom en pied de sidebar, avec un raccourci vers /parametres/compte et la déconnexion ; se referme après l'une ou l'autre action.
  • La correspondance route↔nav utilise les clés i18n layout.nav.<clé> / layout.settings.nav.<clé> — ajouter une entrée de nav est un item de tableau + une clé de locale, rien d'autre.

Sections sans backend

Plus aucune section de la sidebar ne rend un placeholder générique — Liste de courses (ShoppingListPage) a désormais un vrai backend (voir la section dédiée plus bas), et Recettes en a un depuis plus longtemps (catalogue, import depuis des sources externes, favoris — voir plus bas). Le composant ComingSoonPage (components/ui/) qui servait de stub pour ces deux pages a été retiré une fois son dernier appelant (ShoppingListPage) migré vers un vrai rendu.


Parcours profil — onboarding et pages de paramètres

Deux surfaces partagent les mêmes composants de champ (features/profile/ + features/house/) : le wizard d'inscription (une fois) et les pages /parametres/* (à tout moment).

flowchart LR
  SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
  OB1 --> OB2["/onboarding/foyer"]
  OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
  OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
  OB3 --> OB4
  OB4 --> HOME["/ (PlanningPage)"]

  SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
  SETTINGSMENU --> S2["/parametres/preferences"]
  SETTINGSMENU --> S3["/parametres/foyer"]
  SETTINGSMENU --> S4["/parametres/preferences-utilisateur"]
  SETTINGSMENU --> S5["/parametres/credits"]
  • pages/onboarding/ — wizard de 4 écrans (régime → foyer → sources → allergènes), lancé une seule fois juste après l'inscription. Routes top-level RequireAuth, pas nichées sous AppLayout : wizard plein écran sans sidebar (onboarding.scss, même langage visuel que /login//signup). 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. /onboarding/sources est conditionnelle : seulement atteinte si l'étape foyer vient de créer/rejoindre un foyer (OnboardingHouseholdPage's goToNextStep) — sautée sinon, directement vers /onboarding/allergenes. OnboardingSourcesPage elle-même redirige en silence vers l'étape suivante si GET /reference/sources revient vide ou en erreur, plutôt que d'afficher une étape sans rien à choisir.
  • Pages /parametres/* (pages/settings/, dans AppLayout) — l'ancienne HouseholdPage combinée a été éclatée en 5 pages dédiées (voir la section suivante pour le détail de chacune), toutes en hot saving (pas de bouton "Enregistrer" — chaque section sauvegarde peu après la dernière modification, 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 quand 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, et DislikedIngredientsField (nouveau) : champs contrôlés, "dumb" (reçoivent leurs données en props plutôt que de les fetcher). AllergySelect est un <fieldset>/<legend> + grille de cases à cocher, pas un <select multiple> — bien plus repérable/tapable, notamment sur mobile. 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. DislikedIngredientsField — recherche + ajout + puces retirables pour la liste personnelle d'ingrédients "pas aimés" ; réutilise IngredientPicker (voir plus bas) tel quel. Explicitement distinct d'AllergySelect : une préférence de goût, jamais une contrainte médicale — ne déclenche jamais un avertissement de sécurité, juste un badge 🚫 discret sur la fiche recette (RecipeDetailPanel). Sauvegardé via GET/PATCH /profile/disliked-ingredients (remplacement complet, pas de fusion).

Deux bugs de state trouvés en testant dans le navigateur

  1. Course entre navigate() et RedirectIfAuthenticated — voir la note sur RedirectIfAuthenticated plus haut. SignupPage doit maintenant rediriger vers /onboarding/regime, pas /, ce qui a rendu visible une course de state auparavant invisible.
  2. user.dietId périmé sur les pages de paramètres — l'ancienne page combinée initialisait le régime affiché depuis useAuth().user.dietId, un instantané d'AuthContext jamais rafraîchi après une modification faite directement via apiClient (qui ne touche pas le contexte). Une navigation SPA aller-retour sans rechargement complet ré-affichait donc l'ancienne valeur après une sauvegarde. Fix, toujours en place dans PreferencesPage : la page fetch son propre profil frais (apiClient.me()) au montage plutôt que de dépendre du contexte, et AuthContext.refreshUser() (re-fetch GET /auth/me) est appelée après une sauvegarde réussie du régime — pour que le reste de l'app (pas seulement cette page) reste cohérent.

Thème (clair/sombre/système)

features/theme/ThemeContext.tsx — ce que la note "future switch de thème" plus bas (SCSS et theming) anticipait est maintenant réel.

  • ThemePreference = "LIGHT" | "DARK" | "SYSTEM" (packages/shared's THEME_PREFERENCES). ThemeProvider doit être imbriqué dans AuthProvider (il lit useAuth().user).
  • L'effet qui charge la préférence est indexé sur user?.id, pas sur user lui-même — AuthContext's user change de référence à chaque refreshUser(), ce qui ne doit pas redéclencher un fetch des préférences (documenté par un commentaire biome-ignore lint/correctness/useExhaustiveDependencies).
  • Sans utilisateur : retombe sur "SYSTEM". Avec un utilisateur : apiClient.getPreferences() (GET /preferences), échec avalé silencieusement — même posture "non fatale" que AuthContext's propre me().
  • applyTheme(theme) : SYSTEM supprime l'attribut document.documentElement.dataset.theme plutôt que d'y écrire la chaîne littérale "system" — sans attribut data-theme, la media query prefers-color-scheme de _theme.scss reprend la main naturellement. LIGHT/DARK posent data-theme="light"/"dark".
  • setTheme(newTheme) : appelle apiClient.updatePreferences(theme) (PATCH /preferences) avant de mettre à jour l'état local — la persistance est côté serveur (via l'API/la base), contrairement au repli de la sidebar qui, lui, ne vit que dans localStorage.
  • Consommé par UserPreferencesPage (/parametres/preferences-utilisateur) via useTheme() — un simple groupe de boutons radio (RadioOption × THEME_PREFERENCES).

Pages de paramètres (/parametres/*)

L'ancienne HouseholdPage combinée est éclatée en 5 pages dédiées (pages/settings/*.tsx, styles partagés settings-pages.scss) :

  • AccountSettingsPage (/compte) — identité en lecture seule (prénom/nom/email, éditer n'est pas encore une fonctionnalité demandée) plus une "zone dangereuse" : suppression du compte après re-saisie du mot de passe (useAuth().deleteAccount(password)DELETE /auth/me).
  • PreferencesPage (/preferences, "Préférences alimentaires") — le volet personnel : régime (DietSelect, sauvegarde immédiate + refreshUser()), allergies/intolérances (deux AllergySelect, debounce 500ms), et DislikedIngredientsField (debounce 500ms) — le pendant "toujours modifiable" des étapes régime/allergènes du wizard, mêmes composants de champ.
  • HouseholdSettingsPage (/foyer) — le volet foyer. Deux layouts selon useAuth().user.houseId :
    • Sans foyer : créer (POST /house) ou rejoindre par code d'invitation (POST /house/join).
    • Avec foyer : nom renommable (debounce 600ms, PATCH /house/current), code d'invitation affiché + copie presse-papier, liste des membres avec badge admin, bouton de retrait réservé à l'admin (DELETE /house/members/:id), une section Sources (SourceSelect — n'importe quel membre, pas seulement l'admin, peut activer/désactiver quelles sources externes le foyer voit, debounce 500ms, PATCH /house/current/sources), et soit la suppression du foyer (admin, confirmation en deux temps, DELETE /house/current) soit le départ (non-admin, sans confirmation puisque ça n'affecte que le partant, POST /house/leave). Recharge GET /house/current après chaque mutation plutôt qu'un patch optimiste — délibéré, ce sont des actions peu fréquentes/réfléchies.
  • UserPreferencesPage (/preferences-utilisateur) — juste le thème (voir la section précédente). Nom volontairement distinct de /preferences malgré la collision terminologique : "comment l'app se présente" vs "les contraintes alimentaires du foyer".
  • CreditsPage (/credits) — page d'attribution pure, existe pour satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients (foodiconpack.com, voir plus bas).

Planning (/)

pages/planning/PlanningPage.tsx (+ planning-page.scss) — remplace l'ancienne HomePage. Grille complète de la semaine, pas un simple tableau du jour : 7 colonnes (jours) × 5 lignes (petit-dejeuner, collation, dejeuner, gouter, dinerWEEK_DAYS/MEALS de packages/shared), avec un regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via une bordure appuyée après collation/dejeuner/gouter.

  • Navigation de semaineWeekNavigator (features/planning/WeekNavigator.tsx + week-navigator.scss — extrait de PlanningPage une fois ShoppingListPage devenue une deuxième consommatrice, voir plus bas) : flèches précédent/suivant (addWeeks(weekStart, ±1) de @batch-cooking/date-tools) + un libellé cliquable ouvrant un CalendarPopover (grille mensuelle via buildCalendarMonth, cliquer un jour saute à sa semaine, lundi-first). Un badge "aujourd'hui" s'affiche quand la semaine visible est la semaine courante. Ses propres libellés viennent de common.weekNav.*/ common.calendar.*/common.days.* (pas planning.*) — assez génériques ("Semaine précédente", noms de jours) pour ne pas paraître hors-sujet depuis une page qui n'est pas la grille de planning.
  • GET /planning?date=YYYY-MM-DD renvoie PlanningView | nullnull est un état normal (rien à afficher pour cette semaine), pas un message d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer l'état vide.
  • Ajouter une recette — le "+" d'une case ouvre RecipePickerDialog pour ce créneau (date, weekDay, meal), monté conditionnellement (seulement tant que la case est ouverte) pour que son état interne reparte à zéro à chaque ouverture, sans reset manuel.
  • Mise à jour locale — après ajout/retrait, planning.items est patché côté client plutôt que refetché (un objet Planning factice id: -1 est construit si aucun n'existait encore, puisque seul .items est jamais lu sur cette page). Le retrait est optimiste (retiré localement immédiatement, DELETE /planning/items/:id, restauré en cas d'échec).
  • Chaque recette planifiée s'affiche en pastille "{nom} · ×{portions}" avec un bouton de retrait (✕).

RecipePickerDialog — parcourir, prévisualiser, importer

features/planning/RecipePickerDialog.tsx (+ recipe-picker-dialog.scss) — un seul dialogue, jusqu'à 3 étapes, conçu autour d'un principe : on prévisualise avant de confirmer, rien n'est engagé par un simple clic de ligne.

  1. Parcourir (étape par défaut) — embarque RecipeTabs + RecipeTable/RecipeDetailPanel (onglets réguliers) ou RecipeSourcesPanel (onglet d'une source) : exactement l'UI du catalogue /recettes, avec en plus trois filtres propres au contexte "je cherche quoi cuisiner" (pas juste "je consulte") : un filtre multi-ingrédients (IngredientPicker), un filtre multi-régimes (DietTagSelect), et une case "convient à tout le foyer" (affichée seulement si le viewer a un foyer) — tous branchés sur les query params de GET /recipes. Cliquer une ligne sélectionne/prévisualise seulement, jamais ne valide — un pied de dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe) est ce qui agit réellement.
  2. Confirmer les portions (une vraie recette est prévisualisée) — petit formulaire "combien de portions ?" (pré-rempli depuis RecipeSummaryView.portions), puis POST /planning/items.
  3. Revue intégrée (un item de source pas encore importé est prévisualisé et nécessite une intervention humaine) — rend RecipeImportForm directement à l'intérieur du même Dialog (planningSlot transmis pour qu'un import réussi ajoute aussi au planning en un seul submit) : évite de naviguer vers ImportRecipePage et de perdre la recherche/les filtres/le créneau du picker.

L'import transparent — décrit dans le composant comme "la seule action de toute l'app qui importe vraiment un item de source... puisqu'un item de source ne devient une vraie Recipe sauvegardée qu'en conséquence du fait que quelqu'un l'ajoute à son planning" :

  1. GET /sources/:sourceKey/preview/:externalIdRecipeImportDraftView.
  2. tryBuildCompleteImport(draft) (recipe-import-draft.ts) tente de construire un CreateRecipeInput soumissible sans aucun formulaire, sans personne impliquée — renvoie null dès qu'un jugement humain est nécessaire (une ligne d'ingrédient non résolue, portions manquant).
  3. Si non-null : POST /sources/:sourceKey/import/:externalId puis POST /planning/items — ajouté au créneau sans écran supplémentaire, comme n'importe quelle autre recette.
  4. Si tryBuildCompleteImport renvoie null, ou si l'un des deux appels échoue : bascule sur l'étape 3 ci-dessus (revue intégrée).
  5. Cas limite géré explicitement : si l'import réussit mais que l'ajout au planning échoue ensuite, la recette est déjà sauvegardée — plutôt que de retenter tout l'import, navigation vers /recettes/:id (même repli que RecipeImportForm's propre submit, voir plus bas).

Liste de courses (/liste-de-courses)

pages/shopping-list/ShoppingListPage.tsx (+ shopping-list-page.scss, shopping-list.ts) — même WeekNavigator que PlanningPage (semaine sélectionnable), mais un rendu bien plus simple en dessous : une liste, volontairement pas une grille. Délibérément en lecture seule — pas de case à cocher/état "acheté" à faire persister : la source de vérité de ce qu'il faut acheter reste le planning lui-même, pas une liste de courses séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est ajoutée/retirée après coup.

  • GET /shopping-list?date=YYYY-MM-DD renvoie toujours un ShoppingListView (jamais null, contrairement à GET /planning) — chaque ingrédient de chaque recette planifiée cette semaine, déjà sommé côté serveur (RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions, additionné par paire (ingredientId, unitId) — voir backend-architecture.md). Aucun état foyer/semaine vide n'est un cas d'erreur séparé : les deux redescendent en un items: [] normal, affiché via shoppingList.empty.
  • Groupement par rayonshopping-list.ts's groupShoppingListItems (logique pure, extraite du composant, testable sans monter i18next) trie les lignes par IngredientCategory (même catalogue "rayon de supermarché" que IngredientPicker, ordre canonique INGREDIENT_CATEGORIES de packages/shared), puis alphabétiquement à l'intérieur d'un rayon — sur le libellé déjà traduit (pas la key anglaise), pour un tri qui se lit correctement en français. Chaque en-tête de groupe réutilise CategoryIcon/recipes.form.category.<clé> (ingredient-icons.tsx), déjà utilisés par IngredientPicker — pas de nouveau jeu d'icônes/libellés pour cette page.
  • Formatage des quantitésshopping-list.ts's formatShoppingListQuantity (Number.prototype.toLocaleString("fr-FR", {maximumFractionDigits: 2})) — évite qu'une somme de plusieurs recettes (addition flottante côté serveur) affiche un résidu du type "149.99999999999997".
  • Aucune conversion d'unité — deux lignes du même ingrédient dans deux unités différentes (ex. "tomate" en grammes sur une recette, en kilogrammes sur une autre) restent deux lignes séparées plutôt que d'être fusionnées par une conversion devinée ; voir UnitView.toBaseFactor's doc comment (packages/shared) — la conversion inter-unités reste posée comme fondation pour plus tard, pas construite.

Recettes — catalogue, favoris, import depuis une source externe

pages/recipes/RecipesPage.tsx — routée sur /recettes, /recettes/:id et /recettes/sources/:sourceKey/:externalId (le même composant pour les trois) : une vue maître-détail, pas une navigation vers une page séparée — la barre d'onglets + le tableau restent montés, seul le panneau de détail change avec le paramètre d'URL.

Onglets — RecipeTabs.tsx

Quatre onglets réels, en base — favoris / perso / foyer / publique — pas de "toutes" : toute recette tombe sous exactement un des trois derniers via sa propre visibility, favoris est un filtre transverse orthogonal. Plus un onglet par source externe activée pour le foyer (chaque source activée devient sa propre tab) — valeur "source:<key>", icône propre à la source (iconUrl si elle en a un, sinon SourcesIcon générique), nom non traduit (nom propre). Concept propre au web : absent du type RecipeTab partagé, l'API n'a pas de valeur tab=source:... — parcourir une source est un endpoint entièrement différent (GET /sources/:sourceKey/browse). useEnabledSources.ts (Promise.all([getSources(), getHouseSourceIds()])) calcule la liste des sources activées, partagé par RecipesPage et RecipePickerDialog.

Parcourir une source — RecipeSourcesPanel.tsx

Contenu de l'onglet d'une source : sa propre paire maître-détail — SourceItemTable (liste paginée, GET /sources/:sourceKey/browse?query=&cursor=, nextCursor) + RecipeDetailPanel pour la prévisualisation. Scopé à un seul sourceKey (prop fixe) — remonté avec key={sourceKey} en changeant de source, même convention "monté seulement tant que pertinent" que Dialog.

Pagination en scroll infini, pas de bouton "voir plus" : une ligne sentinelle invisible en fin de liste (SourceItemTable, un IntersectionObserver scopé à son propre conteneur scrollable) déclenche le chargement de la page suivante dès qu'elle approche du bas. RecipeSourcesPanel précharge en plus la page suivante dès que la page courante s'affiche (avant même que la sentinelle soit visible), pour qu'un défilement rapide tombe le plus souvent sur une réponse déjà arrivée. Pendant un chargement (préchargé ou non), des lignes squelettes qui pulsent s'ajoutent en bas de la liste plutôt que de laisser un vide ; un échec affiche un message avec un lien "Réessayer" sans effacer les lignes déjà chargées.

Clic sur une ligne :

  • Déjà importé (alreadyImported && recipeId !== null) : GET /recipes/:id, prévisualisé en état "loaded".
  • Pas encore importé : GET /sources/:sourceKey/preview/:externalId, prévisualisé en état "loaded-draft".

Flux d'import complet (parcourir → prévisualiser → revue → sauvegarder)

  • PrévisualisationRecipeDetailPanel's état "loaded-draft" : photo, icône de lien vers la source (SourceLinkIcon, ouvre l'URL d'origine dans un nouvel onglet), nom, portions, description, étapes (avec surlignage des techniques, voir plus bas) — pas d'actions favori/modifier/supprimer, et volontairement pas de bouton "importer" manuel : un item de source n'est sauvegardé qu'en conséquence de son ajout au planning (voir RecipePickerDialog plus haut) ou d'une soumission de revue explicite.
  • Formulaire de revue — RecipeImportForm.tsx — pré-rempli depuis le brouillon, structurellement identique à RecipeFormPage (mêmes IngredientRow/IngredientPicker/StepListEditor/DietTagSelect, même forme de payload CreateRecipeInput), plus une section "à compléter" pour les lignes d'ingrédient non résolues automatiquement (ingredient-matcher.ts, côté API) : chaque ligne montre son texte brut, un bouton "Choisir un ingrédient" (ouvre IngredientPicker, la quantité brute est conservée) ou un bouton pour l'écarter. canSubmit exige zéro ligne non résolue restante — aucune recette invalide n'est jamais silencieusement devinée/abandonnée (décision produit explicite). Soumet vers POST /sources/:sourceKey/import/:externalId au lieu de POST /recipes. Prop optionnelle planningSlot : en cas de succès, appelle aussi POST /planning/items avec les portions du formulaire.
  • RecipeImportForm a été extrait d'ImportRecipePage pour que RecipePickerDialog puisse l'embarquer directement comme une de ses étapes — pages/recipes/ImportRecipePage.tsx (/recettes/importer/:sourceKey/:externalId) n'en est plus que le wrapper d'une route de secours autonome et partageable (favori enregistré, page rechargée en plein milieu du flux), plus le chemin principal. Elle décode toujours défensivement ?planningDate=&planningWeekDay=&planningMeal= (validés contre WEEK_DAYS/MEALS) pour ce chemin historique.

Surlignage des techniques et infobulle

  • highlight-tech-steps.ts's splitDescriptionByTechSteps(description, techSteps) découpe une description d'étape en segments texte/technique à partir des offsets start/end de chaque StepTechStepView (calculés côté API par matchTechStepSpans, voir backend-architecture.md). Trie défensivement par start et élimine silencieusement tout span aux bornes invalides (négatif, hors texte, chevauchant un span déjà accepté) — dégrade en "ne pas surligner celui-ci" plutôt que de planter/déformer l'affichage.
  • StepDescription.tsx rend les segments : texte brut tel quel, technique entourée d'un vrai <button type="button"> (pas un <mark> — nativement focusable au clavier/lecteur d'écran) dans un Tooltip (components/ui/Tooltip.tsx) dont le contenu est t(\catalog.techSteps.${techStep.key}`)`.

Icônes d'ingrédients et badge "reproductible"

  • ingredient-icons.tsx — ~20 pictogrammes génériques (remplace un ancien schéma à un emoji par ingrédient, 437 cas, jugé peu professionnel/incohérent en revue produit), groupés par type de chose (légume, bouteille, fromage…) plutôt que par ingrédient précis. La plupart viennent du pack CC BY 4.0 de foodiconpack.com (voir CreditsPage) via un wrapper FilledIcon (glyphes pleins) ; trois (BreadIcon, DoughIcon, SproutIcon) sans bon équivalent dans ce pack restent dessinés à la main (wrapper Icon, traits) — dimensionnés en CSS pour que le mélange se lise comme un seul jeu cohérent. CATEGORY_ICON/SUBCATEGORY_ICON donnent une icône représentative par catégorie/sous-catégorie pour les lignes de IngredientPicker (un choix éditorial, pas une donnée dérivée).
  • ReproducibleBadge.tsx — rien si !reproducible (IngredientView.reproducible, "raisonnablement faisable maison"). Pastille simple dans la grille du picker, ou (avec searchLabel, dans IngredientRow) un lien <a> classique (pas un <Link> router, volontairement, pour ne jamais faire quitter un formulaire de recette en cours) ouvrant /recettes?search=<nom> dans un nouvel onglet.

Badges régime/allergènes et autres pièces

  • AllergenBadges.tsx / DietBadges.tsx — listes de pastilles simples, rendent null sur un tableau vide. DietBadges utilise le token --color-tag (jamais --color-allergen) pour rester visuellement distinct d'un avertissement de sécurité.
  • DietTagSelect.tsx — fieldset multi-sélection (via CheckboxOption) pour taguer manuellement le(s) régime(s) associé(s) d'une recette — utilisé dans RecipeFormPage, RecipeImportForm, et les filtres de RecipePickerDialog.
  • RecipeTable.tsx — tableau du catalogue (photo/nom+marque favori/allergènes/ régimes), clic sur une ligne = sélection (pas de navigation, le détail s'affiche à côté dans RecipeDetailPanel).
  • RecipeDetailPanel.tsx — union discriminée "empty" | "loading" | "loaded" | "loaded-draft" | "not-found" | "error". Croise les ingrédients de la recette avec dislikedIngredientIds (préférence de goût du viewer) pour n'afficher un badge 🚫 que sur les ingrédients concernés. Prop showActions (défaut true) masque l'étoile favori + les boutons Modifier/Supprimer dans les contextes de prévisualisation seule (RecipePickerDialog, RecipeSourcesPanel).
  • FavoriteStarButton.tsx — bascule optimiste (POST/DELETE /recipes/:id/favorite, restaurée en cas d'échec).
  • IngredientPicker.tsx — parcours à deux niveaux catégorie→sous-catégorie (rayons de supermarché) + recherche + grille de cartes, remplace un ancien dropdown autocomplete plat (400+ ingrédients, la recherche seule ne suffit pas). Un menu "options d'affichage" bascule les badges allergène/régime/reproductible par carte (préférence UI locale, pas persistée). Réutilisé par RecipeFormPage, RecipeImportForm, le filtre ingrédients de RecipePickerDialog, et DislikedIngredientsField.
  • IngredientRow.tsx / StepListEditor.tsx — ligne d'ingrédient sélectionnée (icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné (boutons monter/descendre, pas de drag-and-drop) du formulaire recette.
  • SourceItemTable.tsx — tableau de parcours d'une source (photo+nom, badge "déjà importé" au lieu des colonnes allergènes/régime — un item de source n'est résolu contre les catalogues qu'à la prévisualisation).

Foyer — sources externes activées

  • features/house/SourceSelect.tsx — grille de cases à cocher pour quelles sources un foyer voit, partagée telle quelle par OnboardingSourcesPage et HouseholdSettingsPage's section Sources. Chaque ligne : logo optionnel (iconUrl), nom propre (non traduit), badge officiel/non-officiel (household.sources.official/unofficial) pour juger la fiabilité d'une source scrapée vs une API officielle. Sélection vide = état de départ normal (aucune ligne HouseSource = masqué).

Composants UI partagés (components/ui/)

  • Dialog.tsx (+ dialog.scss) — primitive de modale bâtie sur l'élément <dialog> natif (showModal()), pas une div role="dialog" — piège de focus, fermeture sur Échap et arrière-plan obtenus gratuitement. Première modale de l'app (chaque confirmation avant, ex. les zones dangereuses des pages de paramètres, était une révélation en deux temps inline). Montée seulement pendant qu'elle est ouverte. Le clic sur l'arrière-plan pour fermer compare les coordonnées du clic au rectangle du panneau (getBoundingClientRect() — un clic sur l'élément <dialog> lui-même se produit à la fois pour un vrai clic d'arrière-plan et pour son propre padding non rempli, seule la comparaison de coordonnées distingue les deux), attaché impérativement plutôt que via onClick JSX (Échap couvre déjà le cas clavier). Prop footer optionnelle pour un bandeau d'action épinglé sous le corps défilant — utilisé par RecipePickerDialog.
  • Checkbox.tsx (CheckboxOption) — factorise le balisage "carte sélectionnable" (input natif caché + coche + texte) auparavant dupliqué entre AllergySelect, DietTagSelect, le menu d'affichage d'IngredientPicker. La classe is-selected est posée en JS depuis le booléen checked (un chaînage CSS :has(:checked) s'est révélé peu fiable entre navigateurs).
  • Radio.tsx (RadioOption<T extends string>) — pendant type="radio" de CheckboxOption, même balisage/apparence, sémantique radio native pour l'exclusion mutuelle via un name partagé — utilisé par le sélecteur de thème d'UserPreferencesPage.
  • Tooltip.tsx (+ tooltip.scss) — infobulle CSS-only (pas de librairie de positionnement) : wrapper position: relative, affichée via :hover/:focus-within (aucun état JS). children doit être un seul élément focusable ; cloné pour y attacher aria-describedby (lecteurs d'écran). Utilisé par StepDescription.tsx pour l'infobulle des techniques.

Client API et gestion des erreurs

Voir error-handling.md pour le détail du contrat d'erreurs partagé avec l'API. En résumé côté frontend :

  • ApiClient (api/client.ts) — classe avec instance unique exportée (apiClient), enveloppe fetch avec credentials: "include" (requis pour que le cookie de session httpOnly parte/revienne, l'API et le web étant sur des origines différentes). Lève ApiError (porteuse du code d'erreur) pour toute réponse non-2xx.
  • ErrorMessageService (services/error-message.service.ts) — convertit un code d'erreur numérique en clé de traduction, résolue via i18next.

i18n (internationalisation)

i18next + react-i18next — pas de solution maison : tout le texte affiché (libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale JSON, jamais codé en dur dans un composant.

  • i18n/i18n.ts — initialise l'instance i18next (langue par défaut fr), importé 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 de premier niveau : common (libellés génériques réutilisés partout — dont common.weekNav.*/common.calendar.*/common.days.*, partagés par WeekNavigator entre PlanningPage et ShoppingListPage), errors (voir error-handling.md), auth (auth.login.*/auth.signup.*), layout (nav de la sidebar dont layout.settings.nav.* pour le sous-menu Paramètres — AppLayout), planning (grille de la semaine, RecipePickerDialog), recipes (catalogue, tabs, import), shoppingList (page /liste-de-courses, voir plus haut), onboarding (wizard d'inscription), household (titre + form.*, champs partagés par le wizard et /parametres/foyer, plus household.sources.* pour le badge officiel/non-officiel), account / preferences / userPreferences / credits (les quatre autres pages de paramètres), catalog (libellés des tables de référence — catalog.diets.<key>, catalog.allergens.<key>, catalog.ingredients.<key>, catalog.units.<key>, catalog.techSteps.<key> — un slug key de schema.prisma par entrée, jamais le libellé lui-même stocké en base, voir batch-cooking-modele.md).
  • 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.

Note sur les fichiers .d.ts

Aucun fichier .d.ts écrit à la main dans apps/web : le /// <reference types="vite/client" /> généré par défaut par Vite (habituellement vite-env.d.ts) est remplacé par "types": ["vite/client"] dans tsconfig.app.json — même effet (typage de import.meta.env, imports d'assets), sans fichier dédié.

Côté apps/api, aucune augmentation de type globale n'est utilisée du tout — voir
backend-architecture.md
le profil authentifié passe par res.locals (mécanisme natif d'Express), pas par un declare global sur Express.Request.

SCSS et theming

  • sass (Dart Sass) est utilisé via le support natif de Vite — aucune config supplémentaire needed au-delà d'avoir le package installé (vite.config.ts fixe juste l'API moderne de Sass pour éviter un warning de dépréciation).
  • styles/_theme.scss — tokens de design exposés en custom properties CSS sur :root (--color-primary, --space-md, etc.), pas en simples variables SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui a permis le switch de thème clair/sombre/système (voir Thème plus haut) en redéfinissant juste ces variables sous [data-theme="dark"] (et sous prefers-color-scheme: dark quand aucun data-theme n'est posé, cas SYSTEM), sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit référencer var(--token), jamais une couleur/valeur en dur.
  • styles/global.scss — importé une seule fois, dans main.tsx. Contient uniquement le reset minimal et l'import du thème (@use "./theme"). Rien de spécifique à une page/un composant n'y va.
  • Les tokens étant des custom properties CSS (pas des variables Sass), ils sont disponibles globalement au runtime dès que global.scss a été chargé une fois — un fichier .scss de composant/page les consomme directement via var(--token), sans avoir besoin de @use le partiel theme (ce serait un import sans effet, puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en commentaire à quoi correspond chaque règle un peu non-triviale.

Tests (Cypress + Cucumber)

apps/web/cypress.config.ts déclare deux "testing types" indépendants :

  • e2especPattern couvre à la fois les specs Cypress classiques (cypress/e2e/**/*.cy.ts) et des fichiers Gherkin (cypress/e2e/**/*.feature), via @badeball/cypress-cucumber-preprocessor (+ un bundler esbuild).
  • component (nouveau) — cypress/component/**/*.cy.tsx, monte un seul composant UI générique à la fois (components/ui/*), sans routeur ni backend — lancé via le script cy:run:component (nouveau, à côté de cy:open/cy:run/e2e). Premier test de ce type dans le repo : mounter CheckboxOption isolément, sans jamais visiter une page routée complète.

Motif Gherkin

Pour chaque .feature (ex. planning.feature), un fichier de steps .ts du même nom dans le même dossier (planning.ts) porte les fixtures/steps propres à cette feature (mocks cy.intercept, interactions DOM spécifiques à ce parcours) — délibérément pas partagés entre features, le lookup de steps du préprocesseur Cucumber n'étant pas global à tout cypress/e2e/. Features présentes : account, auth, household-settings, onboarding, planning, preferences, recipe-form, recipes, recipe-sources, user-preferences.

Les steps réellement partagés (par toutes les features) vivent dans cypress/support/step_definitions/ : common.steps.ts (connexion, navigation générique — ex. "I am signed in as {string} {string}"), plus household-mutations.steps.ts, profile-mutations.steps.ts, reference-data.steps.ts. common.steps.ts évite délibérément un hook Cucumber Before() : l'enregistrer ferait lire par le runtime navigateur du préprocesseur un membre d'enum (messages.HookType.BEFORE_TEST_CASE) absent de la version CommonJS de @cucumber/messages sur laquelle ce repo est pinné (pnpm.overrides) — chaque scénario planterait avec "Cannot read properties of undefined". La réinitialisation du profil se fait donc en ligne, dans le step "I am signed in as..." par lequel commence de toute façon chaque chaîne de scénario.

Migration en cours — .cy.ts et .feature coexistent

Les fichiers .cy.ts classiques restants (account, household-settings, layout, planning-page, preferences, recipes, sidebar, smoke, user-preferences) ne sont pas un découpage définitif voulu — c'est une migration en cours vers Cucumber. Certains parcours (bascule favori, suppression de recette) ont déjà été migrés vers un couple .feature+.ts dédié, laissant dans le .cy.ts d'origine ce qui n'est pas encore migré (parcours de navigation/affichage plus larges, contrôles structurels de layout, le check global "redirection si non authentifié" de smoke.cy.ts). Les deux styles tournent dans la même commande cy:run puisqu'ils partagent le même specPattern.

Toujours vrai par ailleurs (hérité de l'état précédent) : les tests 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 la suite Mocha d'apps/api (voir backend-architecture.md, 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) — pas un problème introduit par une modification du code. pnpm --filter web e2e fonctionne normalement en CI 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, pas le conteneur Docker qui sert le frontend buildé, pas le serveur Vite).