Commit graph

171 commits

Author SHA1 Message Date
8e55f3d3f9 fix(recipes): rend StepDescription tolérant à un techSteps absent
All checks were successful
CI / lint (push) Successful in 4m3s
CI / build (push) Successful in 4m36s
CI / intent-service-test (push) Successful in 5m53s
CI / e2e (push) Successful in 8m56s
CI / test (push) Successful in 18m54s
StepDescription faisait `useState(techSteps)` puis `liveTechSteps.flatMap(...)` :
quand la prop `techSteps` arrivait `undefined`, `.flatMap` levait
`TypeError: Cannot read properties of undefined (reading 'flatMap')`, remontée
par Cypress comme une erreur applicative non capturée. Observé de façon
intermittente sur le parcours « modifier une recette » : après enregistrement,
`RecipeFormPage` navigue vers `/recettes/:id`, dont le détail se rendait parfois
avant que `techSteps` ne soit peuplé.

- `StepDescription` : `techSteps` devient optionnel et `liveTechSteps` retombe
  sur `[]` (dans le `useState` et le `useEffect` de resync), pour que tous les
  consommateurs (`.flatMap`, `splitDescriptionSegments`,
  `findTechStepForTemperature`, lookup `activeStepTechStep`) aient toujours un
  vrai tableau — même posture « tolérer la donnée manquante, ne pas planter »
  que le reste du composant.
- `recipe-form.ts` : le mock de la recette 7 envoyait une étape sans `techSteps`,
  contrairement au vrai `StepView` et à tous les autres mocks de la suite —
  corrigé en `techSteps: []`.
- `recipes.cy.ts` : nouveau test de non-régression déterministe — un payload
  d'étape sans `techSteps` doit rendre la description en texte brut. Vérifié
  qu'il échoue bien (`flatMap` undefined) sans le correctif du composant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:04:36 +02:00
b39105ac27 feat(web): page admin de CRUD des didacticiels (#44)
All checks were successful
CI / lint (push) Successful in 2m10s
CI / build (push) Successful in 2m19s
CI / intent-service-test (push) Successful in 4m41s
CI / e2e (push) Successful in 7m19s
CI / test (push) Successful in 16m20s
L'UI par-dessus l'API `/admin/api/tours*`. Nouvelle entrée
`/admin/didacticiels` dans `AdminLayout` (i18n `admin.nav.tours`), route
sous `RequireAdmin`+`AdminLayout` dans `App.tsx`.

`ToursPage.tsx` : liste des scénarios à gauche (nom, état activé/désactivé,
nombre d'étapes) ; à droite l'éditeur du scénario sélectionné —
- renommer, activer/désactiver ;
- éditer la liste d'étapes (route, cible via `<select>` alimenté par
  `GET /tours/targets`, titre, contenu), réordonner (↑/↓), ajouter,
  retirer ;
- « Enregistrer » → un seul `PATCH /:id` qui remplace tout (name/enabled +
  étapes rénumérotées `order` = index), erreurs API affichées telles quelles ;
- « Supprimer le scénario » (confirmation `window.confirm`) ;
- « Nouveau scénario » → formulaire clé + nom → `POST /`.

`admin-client.ts` gagne `getTourScenarios` / `getTourTargets` /
`createTourScenario` / `updateTourScenario` / `deleteTourScenario`.

Tests Cypress (`admin-tours.cy.ts`, 4) : liste (activés + désactivés,
nombre d'étapes) ; édition nom + réordonnancement d'étapes → un seul PATCH
avec la liste complète réordonnée ; création → POST `{key,name}` ;
suppression après confirmation → DELETE. 4/4 en local ; `pnpm build` +
`pnpm lint` verts.

Closes #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:53:38 +02:00
38fcf03417 feat(web): moteur de tutoriel guidé driver.js (#42)
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build (push) Successful in 4m53s
CI / intent-service-test (push) Successful in 5m52s
CI / e2e (push) Successful in 10m30s
CI / test (push) Successful in 18m43s
Consomme le socle backend de #42/#44 (`GET /reference/tours`,
`GET/POST /profile/tours`). Deux tours : orientation générale (auto sur
`/`) et correction des métadonnées de recette (auto à l'ouverture du
premier popover de correction).

- Dépendance `driver.js` (~5 ko, MIT, zéro dépendance).
- `features/tour/tour-engine.ts` : wrapper vanilla autour de `driver.js` —
  gère ce que la lib ne fait pas seule : la navigation React Router entre
  étapes situées sur des routes différentes, et un `onFinish` unique
  (`"completed"` seulement si on atteint la fin, `"skipped"` pour la croix
  / Échap / clic sur l'overlay).
- `features/tour/TourContext.tsx` : `TourProvider` (charge scénarios +
  progression, keyed sur `user?.id` comme `ThemeContext`) et `useTour()`
  (`startTour(key)` / `isTourCompleted(key)` / `tours` / `isReady`).
  `isReady` est **calculé au render** contre l'id utilisateur chargé — pas
  stocké — pour qu'un changement de `user` le repasse à `false`
  immédiatement, avant qu'un effet enfant (l'auto-déclenchement) ne tourne
  sur des données de complétion périmées.
- `main.tsx` : `<TourProvider>` sous `ThemeProvider`.
- Attributs `data-tour-*` : liens de nav (`AppLayout`), grille / bouton `+`
  du 1er créneau / bouton Cuisiner (`PlanningPage`), page liste de courses
  (`ShoppingListPage`), description d'étape + section technique du popover
  (`StepDescription` / `TechStepCorrectionPopover`).
- Déclenchement auto : tour `general` sur `/` (`PlanningPage`, ref-gardé),
  tour `techStepMetadata` à `activeCorrection` non-null (`StepDescription`).
- Section « Revoir un tutoriel » dans `/parametres/preferences-utilisateur`
  — un `map` sur `tours`, un bouton « Revoir » par scénario.
- `tour.scss` re-skinne le popover `driver.js` sur les tokens du thème.
- i18n `tour.*` (chrome uniquement : boutons du popover, section « Revoir » ;
  le contenu des étapes vient de la base).

Tests Cypress (`tour.feature`, 5 scénarios) : auto-démarrage + élément
surligné pour un nouveau venu ; « Terminé » → complétion enregistrée
`skipped:false` ; croix → `skipped:true` ; un tour déjà vu ne se relance
pas ; « Revoir » le relance depuis les paramètres. Vérifié 5/5 en local.
`pnpm build` + `pnpm lint` verts.

Refs #42
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 06:17:06 +02:00
2beff139a3 feat(api): CRUD admin des scénarios de didacticiel (#44)
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 2m59s
CI / intent-service-test (push) Successful in 4m22s
CI / e2e (push) Successful in 9m4s
CI / test (push) Successful in 17m32s
Deuxième volet backend du tutoriel guidé, sur le socle de #42/#44 : permet
à un admin de créer/éditer/supprimer les scénarios que
`GET /reference/tours` sert au moteur front.

`/admin/api/tours` (agrégateur `admin.routes.ts`, tout derrière
`requireAdmin`) :
- `GET /` — tous les scénarios (activés **et** désactivés) + étapes
  ordonnées.
- `GET /targets` — le catalogue `TourTarget` pour le sélecteur de cible de
  l'éditeur.
- `POST /` — crée un scénario vide (`key` + `name`) ; 409
  `TOUR_KEY_ALREADY_IN_USE` (nouveau code 4025) sur une clé prise.
- `PATCH /:id` — édite `name`/`enabled` **et remplace toute la liste
  d'étapes en bloc** (delete-all puis recreate avec `order` = index), même
  convention que `updateRecipe` pour les étapes d'une recette. `key`
  immuable. 404 `TOUR_NOT_FOUND` (id inconnu), 404 `TOUR_TARGET_NOT_FOUND`
  (nouveau code 4054, `targetKey` d'une étape hors catalogue — vérifié
  avant d'écrire, les étapes réelles restent intactes si la requête est
  rejetée).
- `DELETE /:id` — 204 ; les étapes et les `TourProgress` des utilisateurs
  cascadent.

Tests Mocha (`admin-tours.test.ts`) : 401 sans session admin sur chaque
route ; list (activés+désactivés) ; targets ; create→patch pour remplir
les étapes ; remplacement en bloc ; 409 clé dupliquée ; 404 cible
inconnue (sans toucher aux étapes existantes) ; 404 id inconnu ; delete +
cascade vérifiée ; un scénario désactivé disparaît de `/reference/tours`
mais reste dans la liste admin.

Refs #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:39:37 +02:00
cd8ce42df7 feat(api): modèle de données + API des scénarios de didacticiel (#42/#44)
All checks were successful
CI / lint (push) Successful in 1m0s
CI / build (push) Successful in 1m16s
CI / intent-service-test (push) Successful in 3m34s
CI / e2e (push) Successful in 6m38s
CI / test (push) Successful in 15m49s
Socle backend, entièrement testable en CI, pour le tutoriel guidé. Le
moteur front (driver.js), l'instrumentation `data-tour-*` et le CRUD admin
viendront ensuite.

Schéma (`schema.prisma` + migration `20260906120000_tour_scenarios`) — les
4 modèles de #44, qui révisent le registre statique prévu par #42 :
- `TourTarget` (`key` PK + `description`) : catalogue des cibles de
  surlignage valides, seedé, comme `TechStep`/`Unit`.
- `TourScenario` (`key` stable, `name`, `enabled`) : un scénario rejouable.
- `TourScenarioStep` (`order`, `route?`, `targetKey`, `title`, `content`) :
  `title`/`content` sont du **texte libre**, pas des clés i18n (#44 :
  contenu administrable → texte, comme le nom d'une recette).
- `TourProgress` (`userProfileId` + `scenarioId`, PK composite) : présence
  d'une ligne = scénario vu (terminé *ou* passé).

Seed (`reference-seed-data.ts`) : les 9 `TourTarget` correspondant aux
`data-tour-*` à poser côté front, + les 2 scénarios de #42 (`general`,
`techStepMetadata`) **créés seulement s'ils sont absents** (un admin en
devient propriétaire ensuite). `techStepMetadata` reste court pour
l'instant — #36 (popover qui se ferme en pleine sélection) est ouvert, v1
ne couvre que les gestes qui marchent déjà.

API :
- `GET /reference/tours` (public) → scénarios **activés** + étapes
  ordonnées, forme consommée directement par le futur `useTour()`.
- `GET /profile/tours` → `{ completedTourKeys: string[] }`.
- `POST /profile/tours/:tourKey/complete` → upsert idempotent ; corps vide
  = terminé, `{ skipped: true }` = passé. Émet `analytics.recordEvent`
  `tour.completed` / `tour.skipped` (le dashboard admin agrège déjà tout
  type d'`AnalyticsEvent` génériquement — analytics de #44 quasi gratuites).
  404 `TOUR_NOT_FOUND` (nouveau code 4053) sur une clé inconnue/désactivée.

`reset-db.ts` truncate les 4 nouvelles tables (reseedées juste après).

Tests Mocha : `GET /reference/tours` (scénarios + étapes ordonnées,
exclusion d'un scénario désactivé) ; `/profile/tours` GET+POST (upsert
idempotent, `skipped`, 404 clé inconnue, 404 tour désactivé, isolation
par profil). Pas de base Postgres locale → exécutés en CI.

Refs #42, #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:08:43 +02:00
da7b077098 feat(web): page vitrine publique à la racine (#41)
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build (push) Successful in 2m16s
CI / intent-service-test (push) Successful in 3m47s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m48s
Un visiteur déconnecté sur `/` voit désormais une vraie page de
présentation avant qu'on lui demande de se connecter, au lieu d'être
renvoyé directement vers `/login`.

- `RequireAuth.tsx` devient sensible au chemin : dans sa branche `!user`,
  `pathname === "/"` rend `<LandingPage />` ; toutes les autres routes
  protégées redirigent vers `/login` exactement comme avant. Aucun
  changement de la table de routes — `/` reste `<PlanningPage>` niché sous
  `RequireAuth`+`AppLayout`, et `AppLayout` (donc la sidebar) ne monte
  jamais pour la vitrine.
- `pages/landing/LandingPage.tsx` + `landing-page.scss` : hero (accroche +
  illustration SVG dessinée à la main dans le langage de `lucide-react`,
  zéro dépendance ni asset externe) + deux CTA, grille de 4
  fonctionnalités (icônes lucide), section « aperçu », CTA final. Copie
  entièrement sous le namespace i18n `landing.*`.
- `index.html` : favicon SVG (monogramme « bC ») + `<meta description>` —
  `/` est maintenant une page publique partageable.
- Les 3 captures d'écran réelles de la section « aperçu » sont servies
  statiquement depuis `public/landing/` (un fichier manquant retombe sur
  son `alt`, ne casse pas le build) ; elles restent à produire par un
  humain avec l'app lancée sur des données réelles — cf.
  `public/landing/README.md`.
- Tests Cypress `landing.feature` : vitrine visible déconnecté sur `/`,
  CTA vers `/login` et `/signup`, autre route protégée toujours redirigée,
  visiteur connecté voit bien le planning. `smoke.cy.ts` mis à jour pour
  tester la redirection depuis `/recettes` (puisque `/` ne redirige plus).

Closes #41
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:33:41 +02:00
ff36cefcde feat(web): plomberie web pour la coquille Capacitor Android (#39 phase 2)
Some checks failed
CI / lint (push) Failing after 1m59s
CI / build (push) Successful in 3m55s
CI / test (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / e2e (push) Has been cancelled
Tout ce qui est vérifiable en CI de la phase 2 du portage mobile. La
génération du projet natif (`npx cap add android`), le build de l'APK et
le test sur appareil restent une étape manuelle (pas d'Android Studio ici)
— cf. le corps de l'issue #39.

- Dépendances : `@capacitor/core`, `@capacitor/android`,
  `@capacitor/preferences` (+ `@capacitor/cli` en dev), toutes en 8.5.x.
- `capacitor.config.ts` : `appId` `fr.kyuno.batchcooking`, `webDir: "dist"`.
- `apps/web/src/api/capacitor-token-store.ts` : implémentation `TokenStore`
  adossée à `@capacitor/preferences`, branchée via `setTokenStore()` (le
  point d'injection posé en phase 1). Importée dynamiquement dans
  `main.tsx` uniquement quand `Capacitor.isNativePlatform()` — le bundle
  web ne charge jamais `@capacitor/preferences` (vérifié : chunk séparé de
  0,5 ko jamais requis côté navigateur).
- `main.tsx` : `HashRouter` en natif (le bundle packagé sert `dist/`
  depuis l'origine locale de la WebView, sans réécriture des liens
  profonds vers `index.html`), `BrowserRouter` inchangé sur le web ;
  bootstrap async pour poser le token store avant le premier rendu.
- `build:capacitor` (`vite build --mode capacitor`) + `.env.capacitor.example` :
  le build packagé a besoin de l'URL absolue de l'API en dur (pas de
  "même origine que la page" possible).

Vérifié : `pnpm build` + `pnpm lint` verts ; Cypress smoke/layout/sidebar
14/14 en local (le refactor du bootstrap et le choix de routeur ne
cassent rien côté web).

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:56:15 +02:00
a8d86c7099 feat(web): passe responsive téléphone pour le contenu des pages (#39 phase 3)
All checks were successful
CI / lint (push) Successful in 2m28s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m27s
CI / e2e (push) Successful in 8m24s
CI / test (push) Successful in 15m28s
Réutilise la philosophie déjà en place pour la sidebar (défilement
horizontal plutôt que compression illisible) au lieu d'un nouveau patron
par page. Tout est strictement additif sous 640px — le rendu desktop est
inchangé.

- `AppLayout.scss` : `.app-content` passe de `--space-xl` (32px) à
  `--space-md` (16px) sous 640px — 64px de largeur utile récupérés sur un
  écran de ~375px.
- `planning-page.scss` : la grille 7 jours défile déjà horizontalement
  (`overflow: auto` + `min-width: 62rem`) ; ce qui casse au tactile, c'est
  le `✕` de suppression d'une recette (`opacity: 0` hors survol) — épinglé
  visible + cible tactile agrandie sous 640px, idem le bouton « + ».
- `dialog.scss` : `.dialog-panel` garde une gouttière latérale et gagne en
  hauteur utile sous 640px (le `--space-2xl` vertical est trop cher sur un
  écran court).
- `recipe-picker-dialog.scss` : le split 2 colonnes forcé du catalogue
  (plus spécifique que le `@media` de recipes.scss) repasse à une colonne
  sous 640px.

Tests : `layout.cy.ts` gagne un bloc « page content under 640px » —
padding de `.app-content`, grille sur son propre scroll sans déborder la
page, `✕` visible au tactile. 10/10 en local.

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:24:13 +02:00
1d8c001035 feat(auth): accepte un jeton Bearer en repli du cookie de session
Some checks failed
CI / lint (push) Successful in 2m6s
CI / build (push) Successful in 4m0s
CI / intent-service-test (push) Successful in 6m47s
CI / test (push) Failing after 8m58s
CI / e2e (push) Successful in 9m28s
Phase 1 du portage mobile (#39) : purement additif côté navigateur, qui
continue d'utiliser le cookie `session` httpOnly inchangé.

- `POST /auth/login` et `POST /auth/signup` renvoient aussi le JWT dans le
  corps de la réponse (`AuthResponse` = `SafeUserProfile` + `token`), en
  plus de poser le cookie. Un client dont l'origine ne peut pas recevoir
  un cookie `SameSite=Lax` (WebView Capacitor) le stocke et le renvoie.
- `requireAuth` lit le cookie en priorité, puis se rabat sur un en-tête
  `Authorization: Bearer <jwt>` quand le cookie est absent. Les deux
  mécanismes coexistent, aucun n'est retiré.
- `apps/web` : `ApiClient` gagne une abstraction `TokenStore`
  (`get`/`set`/`clear`) ; l'implémentation par défaut est un no-op
  navigateur (le cookie fait tout). `_request` ajoute l'en-tête
  `Authorization` quand un jeton existe ; `login`/`signup` le stockent,
  `logout`/`deleteAccount` l'effacent. `setTokenStore` est le point
  d'injection de la version Capacitor (`@capacitor/preferences`), branchée
  en phase 2 quand la dépendance sera ajoutée.

Tests Mocha : `requireAuth` authentifie via l'en-tête `Authorization` sans
cookie, rejette un jeton Bearer invalide ou un schéma non-Bearer ;
`login`/`signup` exposent bien le jeton dans le corps.

Refs #39
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:47:57 +02:00
2251aa93d7 feat(sources): ajoute ohmonbento.com comme source de recettes importable
Some checks failed
CI / lint (push) Successful in 4m8s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 8m15s
CI / test (push) Failing after 15m27s
6e adaptateur, le plus simple des scrapés : `fetchDetail`/`parse`
délèguent tels quels à `jsonLdRecipeAdapter` — une page recette porte un
`Recipe` WP Recipe Maker dans un `@graph` Yoast, déjà géré nativement par
`findRecipeNode`. `list()` : recherche WordPress `GET /?s={query}&paged={N}`
— **même URL que la requête soit vide (tout le catalogue) ou non**, aucun
branchement (contrairement à `750g.ts`). Scrape de cartes
`<div class="entry-thumb"><a href="/<slug>/"><img alt src>` — image dans
`src` (pas de lazy-load `data-src` ici), `href` relatif résolu contre
l'origine. Une page au-delà de la dernière renvoie une vraie 404 → fin de
liste propre (comme `marmiton.ts`). `rekeySourceError` dupliqué localement.

Enregistré dans `registerAllRecipeSources()`.

Tests : `ohmonbento.test.ts` (`stubFetchHtml`, pas de base) — scrape des
cartes, 404 = fin de liste, URL `/?s=` identique pour requête vide, query
+ page dans `/?s={q}&paged={n}`, erreurs re-labellisées `ohMonBento`,
`parse` sur un `@graph` Yoast + WPRM (`recipeYield` en tableau géré).
Vérifié localement, 9/9. + `sources-index.test.ts` couvre
l'enregistrement.

Docs : section `ohmonbento.ts` dans `backend-architecture.md`.

Closes #40

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 01:30:44 +02:00
4906accd85 feat(sources): ajoute quitoque.fr comme source de recettes importable
All checks were successful
CI / build (push) Successful in 3m28s
CI / lint (push) Successful in 3m33s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 7m20s
CI / test (push) Successful in 15m33s
5e adaptateur, calqué sur `marmiton.ts` (le plus fin des wrappers
scrapés) : `fetchDetail`/`parse` délèguent tels quels à
`jsonLdRecipeAdapter` — une page recette quitoque.fr porte un `Recipe`
schema.org, seul élément d'un tableau JSON-LD de premier niveau, déjà géré
nativement par `findRecipeNode`. Seul `list()` est spécifique :
`GET /recettes?page=N` (page 1 implicite si curseur absent), scrape des
cartes `<a class="card__content_header" href="/recettes/…">` (image réelle
dans `data-src`, `src` n'étant qu'un placeholder lazy-load ; `href`
relatif résolu contre l'origine). Extraction agnostique à l'ordre des
attributs.

Pas de vraie recherche serveur sur le site → `query` est un filtre
sous-chaîne « best effort » sur les titres de la page courante (même
lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte
(comme `marmiton.ts`). `rekeySourceError` dupliqué localement (750g /
manger-bouger en ont chacun une copie).

Enregistré dans `registerAllRecipeSources()`.

Tests : `quitoque.test.ts` (`stubFetchHtml`, pas de base) — scrape des
cartes, pagination `?page=N`, filtre par query, fin de liste, erreurs
re-labellisées `quitoque`, `parse` sur un JSON-LD tableau-de-premier-
niveau. Vérifié localement, 9/9. + `sources-index.test.ts` couvre
l'enregistrement.

Docs : section `quitoque.ts` dans `backend-architecture.md` (+ correction
de la ligne « n'enregistre que TheMealDB », devenue fausse).

Closes #37

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 01:11:48 +02:00
1bc9304cdc feat(planning): glisser-déposer un reste sur un autre créneau
All checks were successful
CI / lint (push) Successful in 4m35s
CI / build (push) Successful in 4m51s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m29s
CI / test (push) Successful in 17m43s
Dernière PR (4/4) du chantier « gestion des restes » (issue #34).

`@dnd-kit/core` (+ `@dnd-kit/utilities`) — première et seule utilisation
de DnD de l'app. `PlanningPage` monte un `<DndContext>` (Mouse + Touch +
Keyboard sensors, distance d'activation 5px pour ne pas voler le clic
« Jeter ») autour de la grille et du panier.

- `LeftoversBasket` : chaque ligne a une poignée `⠿` `useDraggable` — le
  nœud draggable est la poignée elle-même (petite), pas toute la `<li>`,
  pour qu'une détection de collision au pointeur (`pointerWithin`, repli
  `closestCenter` pour le clavier) résolve proprement vers la seule case
  sous le curseur. `data` porte `sourceItemId`/`recipeName`/`available`.
- `MealCell` : `useDroppable` (`data: { date, weekDay, meal }`),
  surbrillance `--drop-over` au survol. Un item « restes »
  (`sourceItem !== null`) porte un badge ♻️ sur sa pastille.
- Au drop, `onDragEnd` ouvre `LeftoverAssignDialog` (quantité, défaut =
  disponible, plafonné) → `POST /planning/items/leftover`
  (`apiClient.assignLeftover`) → nouvel item patché dans `planning.items`.

Tests : `planning-leftovers.feature` (Gherkin) — parcours complet drag →
confirmation quantité → pastille ♻️ visible → compteur du panier à jour ;
+ jet d'un reste depuis le panier. Le drag est piloté par événements
souris bruts (mousedown poignée → mousemove ×2 → mouseup cible).
Vérifié localement : 2/2, et aucune régression sur
`leftovers-basket.cy.ts` / `planning-page.cy.ts` / `planning.feature`.

i18n : `planning.leftovers.*` (badge, poignée, dialogue). Docs :
`frontend-architecture.md`.

Closes #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:47:49 +02:00
8278878898 feat(planning): panier des restes dans l'en-tête (lecture seule + jeter)
All checks were successful
CI / lint (push) Successful in 4m15s
CI / build (push) Successful in 4m12s
CI / intent-service-test (push) Successful in 4m55s
CI / e2e (push) Successful in 7m57s
CI / test (push) Successful in 16m14s
PR 3/4 du chantier « gestion des restes » (issue #34).

`LeftoversBasket` (`features/planning/`) : une icône 🧺 avec badge de
compte dans l'en-tête du planning, à côté de `WeekNavigator`. Ouvre un
popover listant les restes encore disponibles de la semaine affichée —
même patron montage conditionnel + fermeture au `mousedown` extérieur que
`CalendarPopover`.

Les lignes sont dérivées à chaque rendu de `planning.items` par
`collectLeftoverLines` (`leftovers-basket.ts`, pur) : chaque item « frais »
(`sourceItem === null`) dont `computeAvailableLeftover` est `> 0`, le
`leftoverUses` étant reconstitué depuis le tableau plat (les items
« restes » qui pointent vers lui) — aucun aller-retour API en plus.

Chaque ligne : recette, jour/repas d'origine, portions restantes, bouton
« Jeter » → `PATCH /planning/items/:id/discard-leftover` (`apiClient.
discardLeftover`), la réponse est patchée dans `planning.items` par id
(`handleLeftoverDiscarded`). État vide quand rien ne reste.

Le glisser-déposer d'un reste sur un autre créneau arrive en PR 4.

Tests : `leftovers-basket.cy.ts` (mode layout) — badge de compte, ouverture
/ fermeture au clic extérieur, contenu d'une ligne, état vide, « Jeter »
qui appelle le PATCH et retire la ligne. Vérifié localement, 3/3.

i18n : `planning.leftovers.*`. Docs : `frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:16:00 +02:00
1c62625c47 feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine
All checks were successful
CI / lint (push) Successful in 52s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 5m38s
CI / intent-service-test (push) Successful in 3m32s
CI / test (push) Successful in 14m17s
PR 2/4 du chantier « gestion des restes » (issue #34).

Deux nouvelles routes sur `/planning` :
- `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201.
  Crée un item « restes » (`sourcePlanningItemId` non nul) sur un créneau,
  même recette que la source (`recipeId` copié, jamais envoyé par le
  client). `portions` revérifié serveur contre le pool réellement
  disponible via `computeAvailableLeftover` → 409
  `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, 4024) si dépassé ; 404
  `PLANNING_ITEM_NOT_FOUND` si source inconnue / d'un autre foyer / elle-
  même un item « restes » (pas de restes-sur-restes).
- `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200.
  Jette tout le pool restant (`discardedPortions += disponible`) ;
  idempotent (rien à jeter → renvoyé inchangé, aucun évènement).

Exclusion : `shopping-list.service.ts` et `cooking-session.service.ts`
filtrent `items: { where: { sourcePlanningItemId: null } }` dans leur
`include` — un créneau qui ne fait que resservir un plat déjà cuisiné n'a
rien à acheter ni à cuisiner. `getPlanningForDate` ne filtre pas (la
grille affiche ces items).

Analytics : `planning.leftover_assigned` / `planning.leftover_discarded`.

Le mapping `PlanningItem` → `PlanningItemView` est factorisé
(`toPlanningItemView`) — 4 chemins le partagent désormais.

Tests (Mocha, DB) : `assignLeftoverPlanningItem` (succès + `recipeId`
copié, 409 sur dépassement, 404 source étrangère/inconnue/« restes »,
cascade à la suppression de la source) ; `discardLeftover` (jette le
disponible, idempotent, ne jette pas ce qui est déjà servi, 404) ;
non-régression liste de courses et session de cuisine ignorant les items
« restes ».

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:53:25 +02:00
eba0ea70bd feat(planning): sépare portions demandées et portions préparées
All checks were successful
CI / lint (push) Successful in 2m37s
CI / intent-service-test (push) Successful in 4m26s
CI / test (push) Successful in 15m8s
CI / build (push) Successful in 2m45s
CI / e2e (push) Successful in 8m6s
Première PR du chantier « gestion des restes » (issue #34) — les
fondations, sans encore d'UI de panier.

À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre,
`portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en
dérive `portions` (ce qui est réellement préparé) via
`computeBatchedPortions` : arrondi au multiple supérieur du rendement de
la recette (on cuisine des lots entiers, jamais une fraction de plat).
L'écart est le futur pool de restes de l'item.

Modèle (`PlanningItem`, purement additif) :
- `portionsRequested` NOT NULL — migration nullable → backfill (`= portions`
  pour les lignes existantes, sans restes à l'époque) → verrou, même
  discipline que `ingredient_unit_catalog`.
- `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et
  `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard.

Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) :
- `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }`
- `computeAvailableLeftover(item)` → pool encore disponible (posé pour les
  PRs suivantes : validation d'écriture côté API, affichage du panier
  côté web — une seule formule, deux appelants)

API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem`
calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView`
et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/
`sourceItem`.

Front : `RecipePickerDialog` — champ « portions demandées » + indice en
direct « → N préparées, M en restes » quand l'arrondi crée un reste ;
envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested`
(un import atterrit à son propre rendement, donc reste nul).

Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ;
nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au
lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures
`planningItem.create` des suites existantes mises à jour ; scénario
Cypress planning ajusté (`portionsRequested` dans le corps).

Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
`frontend-architecture.md`.

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:29:58 +02:00
bc077b770e fix(recipes): garde le popover de correction ouvert pendant la sélection d'un span
Some checks failed
CI / lint (push) Successful in 2m12s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 7m28s
CI / test (push) Failing after 9m18s
CI / e2e (push) Successful in 9m54s
Depuis la refonte #29-#32, attacher un ingrédient/ustensile/température à
une correction de technique demande de surligner le passage concerné dans
la description. Mais le `<p>` de la description est un frère du popover
dans le DOM, et le listener « clic extérieur » du popover (`mousedown`
hors de son propre DOM → `onClose`) fermait le popover dès le `mousedown`
qui démarre ce glisser-sélectionner — avant même le `mouseup`. Le
`mouseup` ouvrait alors un nouveau popover vierge pour le texte
sélectionné, traité comme une toute nouvelle proposition de technique (la
technique choisie et les métadonnées déjà ajoutées étaient perdues).

`TechStepCorrectionPopover` accepte désormais un prop optionnel
`descriptionContainerRef` (le `containerRef` que `StepDescription` a
déjà) : un `mousedown` à l'intérieur n'est plus un clic extérieur.
`StepDescription` pilote déjà explicitement chaque interaction dans la
description (`setActiveCorrection`/`setResolvedMetadataSpan`), donc retirer
cette fermeture concurrente ne perd rien ; un clic vraiment ailleurs ferme
toujours le popover.

Comble aussi le trou de couverture que `TechStepCorrectionPopover.cy.tsx`
prétendait déjà délégué à un scénario e2e inexistant : nouveau test dans
`recipes.cy.ts` avec une vraie sélection de texte (Range/Selection sur les
nœuds `data-offset`, séquence mousedown→mouseup réelle) — vérifié qu'il
échoue sans le correctif.

Closes #36

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 21:01:22 +02:00
1cd886cb8a test(recipes): fiabilise le scénario 3 pages (scroll réel + log de curseurs)
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build (push) Successful in 2m12s
CI / intent-service-test (push) Successful in 4m21s
CI / e2e (push) Successful in 6m11s
CI / test (push) Successful in 14m4s
Le scénario échouait en CI sur deux points de fragilité, pas sur le fond :

1. `IntersectionObserver` ne se redéclenche qu'à une *transition*
   d'intersection — avec des pages courtes la sentinelle restait visible en
   continu et seule la 2e page se chargeait. Les pages font maintenant 15
   éléments (la liste déborde son conteneur) et chaque page suivante est
   tirée par un `scrollTo("bottom")` explicite, répété 5 fois.
2. Une fois la liste débordante, `should("be.visible")` sur un élément
   sorti du conteneur `overflow:auto` échoue (même classe de bug que #1) —
   l'assertion "3e page chargée" fait maintenant `.scrollIntoView()` avant.

L'assertion anti-boucle porte sur le journal des curseurs demandés
(tolérance 2× pour le double-effet React StrictMode du serveur e2e), pas
sur un `Set.size` strict.

Vérifié localement : 7/7 avec le correctif, "Page 3 Item 1" jamais chargée
sans lui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:45:58 +02:00
c842fbb348 test(recipes): pilote le scroll dans le scénario 3 pages des sources
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m39s
CI / intent-service-test (push) Successful in 4m18s
CI / e2e (push) Failing after 7m20s
CI / test (push) Has been cancelled
Le scénario ajouté supposait que l'IntersectionObserver se redéclenchait
tout seul avec une liste courte — faux : il ne se déclenche qu'à une
*transition* d'intersection. Les pages font maintenant 15 éléments (la
liste déborde son conteneur, la sentinelle démarre sous la ligne de
flottaison) et chaque page suivante est demandée via un
`scrollTo("bottom")` explicite, qui produit une vraie transition.

Avec l'ancien code bugué, la sentinelle stale rappelle « cursor-2 » au 2e
scroll : « Page 3 Item 1 » n'apparaît jamais et un curseur est redemandé
— les deux assertions échouent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:32:49 +02:00
c48625cb0c fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00
23be1e4d04 fix(admin): ferme le serveur socket.io en fin de suite de test monitoring
All checks were successful
CI / build (push) Successful in 2m18s
CI / lint (push) Successful in 2m21s
CI / intent-service-test (push) Successful in 3m55s
CI / e2e (push) Successful in 7m45s
CI / test (push) Successful in 14m30s
Le hook `after` de admin-monitoring-socket.test.ts appelait seulement
`broadcast.stop()` (qui ne fait que couper l'intervalle de sonde) et
`prisma.$disconnect()`, mais ne fermait jamais le serveur socket.io ni le
`http.Server` en écoute sur le port 0. socket.io garde alors vivants les
timers ping d'engine.io et la socket d'écoute ; comme Mocha tourne sans
`--exit` (.mocharc.json), ce seul handle en fuite empêche le process de se
terminer une fois le dernier test passé.

C'est ce qui bloquait le job `test` en CI : « N passing » affiché, puis
plusieurs heures d'attente jusqu'au kill du runner (runs #80/#81).

Hisse `io` au scope de la suite et le ferme dans `after` via `io.close()`
— ce qui déconnecte les sockets restantes, stoppe les timers d'engine.io
et ferme le `http.Server` attaché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 19:56:45 +02:00
13d92f8fa8 feat(admin): pousse le monitoring en WebSocket au lieu du polling client
Some checks failed
CI / lint (push) Successful in 2m1s
CI / build (push) Successful in 1m21s
CI / intent-service-test (push) Successful in 4m25s
CI / e2e (push) Successful in 6m12s
CI / test (push) Failing after 3h13m30s
Le tableau de bord /admin/monitoring fonctionnait en polling côté client
(15 s) déclenchant une sonde active côté serveur à chaque appel — deux
onglets admin ouverts = deux séries de sondes redondantes.

Inverse le mécanisme : le serveur sonde une fois par minute et pousse le
résultat aux clients connectés via WebSocket (socket.io, room
"monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST
disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne
en continu dès le démarrage, sans lien avec la présence d'un admin
connecté (le plus simple des deux, coût des sondes négligeable).

Backend :
- ExpressServer (packages/express-tools) expose désormais son
  http.Server sous-jacent (créé explicitement, plus dans .listen()) —
  socket.io s'attache au serveur HTTP lui-même, pas à Express/un port.
- MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage
  puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune
  duplication), diffuse à la room "monitoring", rejoue le dernier
  résultat en cache à tout nouvel admin qui rejoint (pas d'attente
  jusqu'à une minute pour un onglet fraîchement ouvert).
- requireAdminSocket (nouveau) : middleware de handshake socket.io,
  équivalent WebSocket de requireAdmin — même vérification (cookie +
  JWT + tokenVersion), factorisée dans resolveAdminFromToken
  (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent
  le cookie brut (un handshake socket.io ne passe pas par
  cookie-parser).
- GET /admin/api/monitoring supprimée (route + service de test associés).
- server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont
  pas besoin d'un serveur WebSocket.

Frontend :
- MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper
  socket.io-client, interface MonitoringSocketClientLike volontairement
  petite et injectable dans MonitoringPage (prop optionnelle) pour rester
  testable sans serveur réel.
- MonitoringPage : data et connectionState séparés — une coupure de
  connexion après un premier chargement affiche un badge "reconnexion en
  cours" sans jamais effacer le dernier board connu (repose sur la
  reconnexion automatique de socket.io-client). Seul le tout premier
  chargement affiche l'état plein-écran chargement/erreur.
- adminApiClient.getMonitoring() supprimée (plus utilisée).

Tests :
- Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion
  socket.io-client contre un serveur réel démarré dans le test (port 0),
  authentification avec un admin seedé + cookie signé, vérifie la
  réception de monitoring:update et le rejet sans cookie admin valide.
  Les tests heartbeat de l'ancien admin-monitoring.test.ts (route
  /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont
  déplacés vers tech-step-worker.routes.test.ts, leur router d'origine.
- Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un
  client factice, vérifie rendu des cartes, badge de reconnexion sans
  perte du board, états chargement/erreur — pas de test e2e Gherkin
  contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo,
  fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur).
- apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé
  par le test composant ci-dessus.

Vérification : pnpm lint clean, build complet du monorepo clean, suite
Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant
complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha
apps/api non exécutable cette session (Postgres/Docker injoignable,
comme pour #33) — le nouveau test socket.io n'a donc pas pu être
run-vérifié, seulement relu attentivement et vérifié par le lint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:44:39 +02:00
ba131bd8c8 fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front
All checks were successful
CI / build (push) Successful in 2m53s
CI / lint (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 5m6s
CI / e2e (push) Successful in 8m17s
CI / test (push) Successful in 15m19s
Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de
l'API au lieu de la page — même chose à craindre pour tout futur /admin/*
GET sans segment supplémentaire.

Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait
l'API admin exactement sous /admin, la même racine que les pages
/admin/* d'apps/web, servies depuis la même origine/le même process en
prod (image Docker unique). Express matche par chemin, sans égard au
Accept — `GET /admin/monitoring` (route API) et la page du même nom
étaient en conflit direct ; l'API gagnait systématiquement (montée
avant serveStaticFrontend), donc chaque F5 sur cette page recevait le
JSON de l'API plutôt que index.html.

Seule /admin/monitoring collidait réellement aujourd'hui (seul
sous-routeur admin à répondre GET sur sa racine bare) mais le
mécanisme touche toute future route admin nommée comme sa page — d'où
un correctif structurel plutôt qu'un renommage ponctuel.

Correctif : l'API admin est montée sous /admin/api (app.ts), jamais
bare /admin. Toute la surface admin bouge en conséquence
(auth/metrics/monitoring/tech-steps/catalog) ; les pages front
(/admin/monitoring, /admin/catalogue, /admin/corrections...) sont
inchangées, seul admin-client.ts change de base URL. Mis à jour en
cohérence : commentaires JSDoc citant les routes (apps/api + apps/web +
packages/shared), specs/backend-architecture.md,
specs/batch-cooking-modele.md, les intercepts Cypress (globs + un
matcher regex qui avait échappé au premier passage) et les chemins
supertest du suite Mocha admin-*.test.ts.

Simplifie au passage admin-monitoring.cy.ts : le contournement
resourceType ajouté en #32 pour cette même collision n'a plus lieu
d'être une fois les chemins distincts (retour à un intercept déclaratif
simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en
commentaire pour référence future.

Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète
28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*).
Suite Mocha apps/api non exécutable cette session (Postgres/Docker
injoignable) — chemins mis à jour mécaniquement en cohérence avec le
nouveau mount point, à faire tourner en CI/à la prochaine session avec
accès DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:48:07 +02:00
c1aba1f18c fix(admin-monitoring): remplace le filtre resourceType cassé par une vérification manuelle
Some checks failed
CI / lint (push) Failing after 24s
CI / build (push) Successful in 1m53s
CI / intent-service-test (push) Successful in 3m52s
CI / e2e (push) Successful in 7m41s
CI / test (push) Failing after 12m15s
`admin-monitoring.cy.ts` échouait de façon reproductible ("renders one
card per service": cy.wait timeout, "No request ever occurred").

Diagnostiqué par isolation : le matcher déclaratif `resourceType: "fetch"`
de Cypress 13.17.0 ne capte plus du tout la requête réelle de
AdminApiClient.getMonitoring() sur le Chrome 152 de cette machine — un
intercept `{resourceType: "fetch"}` sans filtre d'URL ne voit passer que
/admin/auth/me, jamais /admin/monitoring, alors que le `req.resourceType`
de cette même requête, lu depuis un handler matché par url/method seuls,
vaut bien "fetch" de façon fiable et reproductible. Le filtrage déclaratif
est cassé, pas la requête elle-même.

Remplace le matcher par un handler de fonction qui laisse passer la
requête de document (`cy.visit()` lui-même, sur la même URL que l'API en
same-origin/CI) via `req.continue()` et ne stub que le reste — vérifié
stable sur 3 runs consécutifs.

Corrige au passage le test "shows an error state" qui passait pour la
mauvaise raison : sans serveur API réel, une requête non interceptée
échoue aussi au niveau réseau, ce que MonitoringPage.catch() ne
distingue pas d'un vrai 500 — désormais aliasé et attendu comme le
premier test, pour prouver qu'il dépend bien du mock.

Suite e2e complète: 28/28 fichiers, 116/116 tests verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:20:57 +02:00
3b78582d29 test(recipe-form): corrige la visibilité flaky de l'étape d'ingrédient préchargé
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build (push) Successful in 2m43s
CI / intent-service-test (push) Successful in 4m23s
CI / e2e (push) Successful in 8m0s
CI / test (push) Successful in 14m57s
Le step Gherkin partagé « the recipe should include the ingredient »
utilisait un `.should("be.visible")` nu sur `.ingredient-row__name`.
Sur le formulaire d'édition de recette, la liste d'ingrédients peut se
trouver sous le pli du scroll propre à `.app-content` (Nom/Description/
Photo/Portions/Visibilité/Régimes viennent avant) — un `.should`
seul ne scrolle pas automatiquement, contrairement aux commandes
d'interaction (`.click()`/`.check()`).

Repéré comme bug pré-existant et hors périmètre lors de la vérification
de #29/#30 (voir la note dans tech-step-metadata-editor-redesign.md).
Même correctif que « I should see the highlighted technique »
(common.steps.ts) et « I should see the highlighted temperature »
(recipes.ts) : `.scrollIntoView()` avant l'assertion.

Reproduit avant correctif (recipe-form.feature, scénario « Preloads an
existing recipe's ingredients when editing »: 4/5 puis 5/5 après le
correctif). Suite e2e complète: 27/28 fichiers verts (la seule
exception, admin-monitoring.cy.ts, est un flake distinct et
sans rapport, signalé séparément).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:00:47 +02:00
bd607b17f3 fix(recipes): corrige la numérotation des étapes faussée par le popover de correction
All checks were successful
CI / lint (push) Successful in 2m8s
CI / build (push) Successful in 1m34s
CI / intent-service-test (push) Successful in 3m51s
CI / e2e (push) Successful in 5m25s
CI / test (push) Successful in 13m54s
.recipe-detail-panel__steps utilisait un compteur CSS (counter-reset/
counter-increment: step) sur un sélecteur `li` — un descendant, pas
seulement les enfants directs. Depuis la refonte du popover de correction
(tech-step-metadata-editor-redesign), celui-ci imbrique ses propres <li>
plusieurs niveaux plus bas (une par ingrédient/ustensile/température
attaché, plus la liste du picker de technique — jusqu'à ~74 entrées) :
chacun incrémentait ce même compteur "step", faussant le numéro affiché de
toutes les étapes suivantes dès qu'une correction avec métadonnées
attachées (ou son picker de technique) était ouverte. C'est le bug
rapporté : "les étapes affichées sont numérotées n'importe comment".

Correctif : `li`/`img`/`p` → `> li`/`> img`/`> p` (enfants directs
uniquement) dans cette règle. Même principe pour `img`/`p` : leurs styles
(image bloc, paragraphe inline sans marge) ne doivent s'appliquer qu'au
propre <p>/<img> de l'étape, pas à ceux du popover imbriqué plus loin.

Test de non-régression ajouté (recipes.cy.ts) : ouvre une correction avec
ingrédient + ustensile attachés puis le picker de technique, vérifie via
getComputedStyle que ces <li> imbriqués n'ont pas rejoint la chaîne du
compteur (`counter-increment: none`) alors que les <li> des étapes
elles-mêmes l'ont toujours (`counter-increment: step 1`) — confirmé que ce
test échoue bien sans le correctif (`step 1` au lieu de `none`) avant de le
restaurer.

Vérifié : pnpm lint + build clean. recipes.cy.ts (9/9, dont le nouveau
test), recipes.feature (5/5), recipe-sources.feature (6/6).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:21:16 +02:00
6c5978dd1c feat(recipes): refond le panneau de correction des métadonnées de tech-step
All checks were successful
CI / lint (push) Successful in 2m35s
CI / build (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 4m23s
CI / e2e (push) Successful in 8m0s
CI / test (push) Successful in 15m1s
Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute
l'édition de la température, jusqu'ici la seule des 4 métadonnées sans
aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée
sous celle-ci).

- StepDescription.tsx : le surlignage de température devient cliquable
  (un <button>, plus un <span> mort) — rouvre le popover de correction de
  la technique à laquelle il est rattaché (findTechStepForTemperature,
  résolu par span puisque la température n'a pas d'existence propre, elle
  est une métadonnée de clause comme les ingrédients/ustensiles).
- TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique,
  Ingrédients, Ustensiles, Température) restent toutes visibles en même
  temps — plus de bascule de tout le popover vers un sous-écran pendant
  l'ajout d'un ingrédient/ustensile/température, seule la section
  concernée affiche son propre mini-formulaire, les autres (dont le
  picker de technique) restent inchangées à côté. Le picker de technique
  se cache derrière "Changer" une fois une technique choisie (rien à
  décider une fois confirmée correcte). Chaque ingrédient/ustensile/
  température attaché s'affiche en ligne avec sa citation exacte du texte
  source (nouveau : la traçabilité vers le texte existait déjà en donnée,
  mais n'était affichée nulle part). Nouveau sélecteur à 3 modes
  (°C / Thermostat / Qualitatif) pour la température, cohérent avec le
  modèle de données (un seul des trois à la fois). Icônes SVG par
  catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based
  que layouts/nav-icons.tsx.
- recipes.scss : nouveau langage visuel du popover (sections avec icône,
  lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter"
  en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux
  surlignages (.step-tech-step, .step-temperature) passent de
  `cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais
  déclencheurs cliquables.
- i18n : nouvelles clés (changeTechnique, temperatureSection,
  addTemperature, removeTemperature, temperatureMode.*) ; clé
  currentTechnique supprimée (le pictogramme remplace la phrase "Technique
  retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône
  + le porte déjà visuellement).
- Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests,
  dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau
  scénario Gherkin "cliquer un surlignage de température rouvre le popover
  de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au
  scénario de relabel existant.

Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress
exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx
10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts
8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec
pré-existant et sans rapport (recipe-form.feature, "Preloads an existing
recipe's ingredients when editing") reproduit identiquement avec ce
travail entièrement stashé — confirmé non lié.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:22:01 +02:00
5d4ed0dffb feat(recipes): permet de corriger la température d'un tech-step
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m23s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 6m17s
CI / test (push) Successful in 14m12s
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/
corrections) acceptait ingredients/utensils pour compléter les métadonnées
d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature
n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update
complet de recette. La température ajoutée en #22 n'avait jamais eu son
propre circuit de correction manuelle.

- packages/shared/src/schemas/recipe.ts : nouveau
  manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative
  mutuellement exclusifs — exactement un des trois — même span start/end
  que ingredients/utensils). Le champ temperatures rejoint ingredients/
  utensils dans le même lot combiné (les trois voyagent ensemble : dès que
  l'un est présent, les trois sont résolus, un absent devient [] donc
  effacé — jamais une fusion partielle catégorie par catégorie).
- recipe-tech-step-correction.service.ts : applyManualCorrection écrit
  désormais StepTechStepTemperature (delete+recreate, même logique "le
  manuel remplace tout" que les deux autres tables). raw n'est jamais
  envoyé par le client — dérivé de description.slice(start, end), pour ne
  pas faire confiance à une copie cliente qui pourrait diverger du texte
  réel. loadVisibleStepOrThrow retourne désormais le texte complet de la
  description (pas seulement sa longueur), nécessaire pour cette dérivation.
- specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux
  routes de correction et step_tech_step_temperature n'étaient documentées
  nulle part — comblé au passage.
- Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou
  plusieurs champs renseignés, remplacement sans accumulation, dérivation
  de raw, span hors limites).

Périmètre : back-end uniquement (voir la maquette proposée précédemment).
Le popover de correction (apps/web) ne sera mis à jour que dans une PR
suivante, empilée sur celle-ci.

Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas
de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:53:14 +02:00
bdc1b4c9e9 feat(cooking-session): déplie une recette de l'onglet Recettes au lieu de rediriger
Some checks failed
CI / lint (push) Successful in 52s
CI / build (push) Successful in 1m40s
CI / intent-service-test (push) Successful in 3m57s
CI / e2e (push) Successful in 6m32s
CI / test (push) Failing after 13m50s
Cliquer sur une recette de l'onglet Recettes de /cuisiner naviguait vers
/recettes/:id, quittant la session de cuisine en cours. La ligne devient un
bouton qui déplie désormais le détail complet de la recette directement
sous elle (RecipeDetailPanel, showActions=false — même traitement "aperçu"
que RecipePickerDialog), chargé à la demande via GET /recipes/:id, un seul
dépliant ouvert à la fois (même logique que le sous-menu Paramètres de la
sidebar). RecipesTab est désormais keyed par semaine comme PlanTab, pour
que changer de semaine referme un dépliant devenu obsolète.

Clé i18n recipesTab.viewRecipe supprimée (plus d'appelant).

Tests : cooking-session-page.cy.ts (déplier/replier, un seul ouvert à la
fois) + cooking-session.feature (scénario dédié). pnpm lint + pnpm --filter
web build clean ; 16/16 tests passants (Chrome headless).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:08:47 +02:00
a24ef6a78b feat(cooking-session): regroupe les tâches du stepper en mini-sections par recette
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m25s
CI / intent-service-test (push) Successful in 4m31s
CI / e2e (push) Successful in 6m6s
CI / test (push) Successful in 14m3s
Dans l'onglet Plan de /cuisiner, la liste de tâches d'une phase était un
flux plat — la recette concernée n'apparaissait qu'en petite ligne sous
chaque carte. Regroupe désormais les tâches par combinaison exacte de
recettes (groupTasksByRecipes, cooking-session.ts) : chaque groupe devient
une mini-section titrée par le(s) nom(s) de recette(s), un pool
merged-prep/merged-setup formant sa propre section partagée plutôt que
d'être rattaché à une seule recette. La ligne "pour X" par carte devient
redondante avec le titre de section et est retirée (clé i18n forRecipes
supprimée).

Couverture ajoutée : cooking-session-page.cy.ts (mode layout, 3 sections
distinctes sur la fixture à préchauffages multiples) + cooking-session.feature
(parcours Gherkin, section partagée + sections solo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:47:25 +02:00
ec01fc0107 fix(recipes): persiste les métadonnées de clause des tech-steps à l'édition
All checks were successful
CI / build (push) Successful in 2m44s
CI / lint (push) Successful in 2m50s
CI / intent-service-test (push) Successful in 4m36s
CI / e2e (push) Successful in 8m12s
CI / test (push) Successful in 14m57s
`createRecipeInternal` écrivait, pour chaque technique détectée, ses
ingrédients / ustensiles / températures de clause ; `updateRecipe` et le
script `backfill-tech-steps.ts` n'écrivaient que le span de la technique.
Toute recette ré-enregistrée via l'UI (ou touchée par le backfill) perdait
donc silencieusement ces métadonnées — et `cooking-optimizer.ts` ne pouvait
plus détecter de conflit de four entre deux préchauffages dès qu'une recette
avait été éditée une fois (ni sur les imports antérieurs à la détection des
températures, jamais ré-enregistrés).

- `toStepTechStepCreateInputs` (nouveau, `lib/recipe-matching/`) : seule
  source de vérité du payload `techSteps.create`, utilisée par les trois
  appelants pour qu'ils ne divergent plus.
- `backfill-tech-steps.ts` : `createMany` -> `create` par ligne (createMany
  ne fait pas les écritures imbriquées) + doc à jour.
- Rattrapage automatique au déploiement : `model BackfillState` + migration
  `backfill_state`, script `backfill-runtime.ts` ajouté au CMD Docker après
  `seed-runtime`. Gardé par un marqueur (tourne une seule fois), n'échoue
  jamais le boot (une panne du backfill est loggée, le serveur démarre
  quand même, retente au prochain start).
- Test `PATCH /recipes/:id` : une température de clause survit à l'édition.
- `reset-db.ts` : `backfill_state` ajouté au TRUNCATE.

Vérifié : `tsc --noEmit` + `pnpm --filter api build` + `pnpm lint` OK ;
migration = SQL généré par Prisma pour le modèle, à l'identique. Mocha
`recipe.test.ts` non lancé (Postgres + intent-service requis, indisponibles
ici) — à jouer en CI / sur la machine de dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 09:08:15 +02:00
85268729e6 feat(cooking-session): regroupement d'étapes sensible aux métadonnées d'action
All checks were successful
CI / lint (push) Successful in 1m57s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 7m59s
CI / intent-service-test (push) Successful in 18m44s
CI / test (push) Successful in 28m2s
L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant
aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais
seulement quand elles sont compatibles : deux préchauffages ne fusionnent
que s'ils visent la même température détectée. Deux préchauffages à des
températures différentes restent séparés, ordonnés du plus froid au plus
chaud, et la mise en place porte une note « Four requis à N températures —
enchaîner … ». Défaut optimiste : deux préchauffages sans température
détectée fusionnent quand même (supposés compatibles).

- cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune
  conversion inter-représentations), isPureSetupStep / setupSignature /
  extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour
  l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes
- cooking-session.service.ts : `temperatures` dans l'include + mapping
- packages/shared : CookingTemperatureView, kind « merged-setup »,
  CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes
- web : temperature-label.ts extrait de StepDescription et réutilisé ;
  taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note
- i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.*
- tests : 7 cas Mocha « setup pooling » + test DB cooking-session,
  cas Cypress mode layout + scénario Gherkin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:05:44 +02:00
3d5998a535 merge(main): rattrape main (#21 CI, #22 températures)
Some checks failed
CI / lint (push) Successful in 1m55s
CI / build (push) Successful in 2m46s
CI / e2e (push) Failing after 10m49s
CI / intent-service-test (push) Failing after 18m58s
CI / test (push) Failing after 24m32s
2026-09-03 11:48:04 +02:00
dfe0310b63 feat(cooking-session): onglets recettes/plan + stepper d'étapes cochables
Some checks failed
CI / lint (push) Successful in 2m16s
CI / build (push) Successful in 3m11s
CI / e2e (push) Successful in 7m53s
CI / intent-service-test (push) Successful in 13m57s
CI / test (push) Failing after 17m23s
Remanie /cuisiner : au lieu d'empiler toutes les phases, deux onglets
(Recettes, Plan) et un stepper qui parcourt les phases une à une. Chaque
tâche est cochable ; la progression est mémorisée par semaine dans
localStorage — aucun état serveur, même parti pris que le reste de la page.

- CookingSessionPage : barre d'onglets + PlanTab (stepper, nav
  précédent/suivant, clic direct sur un nœud) + RecipesTab (liste des
  recettes de la semaine, lien vers la fiche)
- cooking-progress.ts : CookingProgressStore (load/save localStorage,
  clé batchcooking:cookingProgress:<semaine>)
- cooking-session.ts : reconcileChecked / phaseProgress (helpers purs)
- réutilise CheckboxOption pour les cartes de tâche cochables
- i18n : cookingSession.tabs / stepper / progress / recipesTab
- tests Cypress (mode layout + Gherkin) mis à jour et étendus

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:12:08 +02:00
fd62dbbabe test(tech-steps): ajoute temperatures aux fixtures StepTechStep
All checks were successful
CI / lint (push) Successful in 2m27s
CI / build (push) Successful in 3m33s
CI / e2e (push) Successful in 8m27s
CI / intent-service-test (push) Successful in 11m14s
CI / test (push) Successful in 20m57s
CI: 7 `deep.equal` sur un `TechStepMatch` / `StepTechStepView` echouaient
(l'objet reel a une cle de plus depuis l'ajout de `temperatures`).

- tech-step-matcher.test.ts x4 : `temperatures: []`, sauf le cas
  « Faire mijoter a feu doux » ou « feu doux » [16,24) est une mention
  qualitative -> `qualitative: "low"`.
- recipe-tech-step-correction.test.ts x3 : `temperatures: []` (l'etape
  fixture « Faire mijoter la sauce. » n'a pas de mention de temperature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:59:52 +02:00
189df73b4c feat(tech-steps): detecte les temperatures dans les etapes
Some checks failed
CI / lint (push) Successful in 2m33s
CI / intent-service-test (push) Failing after 31s
CI / build (push) Successful in 3m30s
CI / e2e (push) Successful in 11m14s
CI / test (push) Failing after 24m39s
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).

services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
  a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
  au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
  extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
  2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.

apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
  clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
  donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
  (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
  reset-db.ts). Persistance + lecture dans `recipe.service.ts`
  (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
  du service de correction ; `sources.service.ts` (apercu d'import) les
  fait transiter.

packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.

apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
  redecoupe les segments non-keyword autour des spans de temperature (la
  logique technique intriquee reste intacte). `?? []` tolere une payload
  d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
  `temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
  ustensiles).

Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:29:03 +02:00
1a28d67218 test(admin): fixe l'intercept monitoring (route page == chemin API)
Some checks failed
CI / build (push) Successful in 2m48s
CI / lint (push) Successful in 3m0s
CI / e2e (push) Successful in 12m51s
CI / intent-service-test (push) Successful in 16m53s
CI / test (push) Failing after 17m9s
La route page `/admin/monitoring` et le chemin API `GET /admin/monitoring`
sont identiques : un glob `**/admin/monitoring` capturait aussi la requete
document du `cy.visit()`. Le hardcode `http://localhost:3000/...` marchait
en local (VITE_API_URL dans apps/web/.env) mais pas en CI (pas de .env ->
API en meme origine sur :5173, donc collision totale) : `cy.wait
(@getMonitoring)` timeout.

`resourceType: "fetch"` epingle l'intercept sur le seul XHR d'AdminApiClient.
Verifie en simulant la CI (sans apps/web/.env) : 16/16 specs admin verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:28:53 +02:00
bbd9afe8aa refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*
Some checks failed
CI / lint (push) Failing after 23s
CI / build (push) Successful in 3m11s
CI / e2e (push) Failing after 8m16s
CI / intent-service-test (push) Successful in 12m49s
CI / test (push) Successful in 21m34s
L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.

Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
  layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
  collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
  RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
  theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
  (les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
  ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
  un ref requestSeq.

Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.

Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
  `app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
  dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.

Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:02:29 +02:00
9532ea4e83 fix(tests): corrige 3 suites Mocha DB revelees par leur 1er run sur main
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
Ces suites ont ete ecrites pendant le dev des features cooking / admin /
hors-catalogue mais jamais executees (pas de Postgres dans ces sessions).
Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas
dans le code merge.

- reference.test.ts : `GET /reference/ingredients` renvoie desormais
  `isPlaceholder` (toujours false) et `displayName` (toujours null) depuis
  la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])`
  exacte est mise a jour.
- cooking-session.test.ts : la fixture "pooling merged-prep" avait 2
  recettes symetriques (chop -> simmer) ; apres mise en commun du chop les
  deux simmer tournent dans l'unique phase de cuisson, donc aucun
  `background` possible (l'optimiseur est correct, cf. le test pur
  equivalent). « Tarte » recoit une etape active `mix` de plus pour que son
  simmer flotte en background pendant que « Soupe » est en hold.
- admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/
  `.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc
  le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de
  409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant
  l'attente de 100 ms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:00:43 +02:00
16f593d5d0 feat(recipes): permet d'ajouter des ingredients hors-catalogue
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 1m55s
CI / e2e (push) Successful in 8m37s
CI / intent-service-test (push) Successful in 17m3s
CI / test (push) Failing after 3h14m19s
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:42 +02:00
fcaecb4e07 feat(admin): tri des corrections + declenchement du gate F1/backfill
Some checks failed
CI / lint (push) Successful in 1m58s
CI / build (push) Successful in 2m35s
CI / e2e (push) Failing after 5m24s
CI / intent-service-test (push) Successful in 22m51s
CI / test (push) Failing after 23m21s
PR 5 (derniere) du chantier admin. Remplace le duo CLI
list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI.

API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) :
- GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par
  technique, enrichies du contexte de la correction source.
- GET /corrections : corrections brutes filtrables, incluant les
  suppressions correctedTechStepId:null invisibles ailleurs.
- PATCH /suggestions/:id : edite synonymes/phrases et/ou status.
- GET /training-data-snippet : bloc training_data.py a coller (lecture
  seule).
- POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis
  si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire
  -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false.
  N'edite pas le .py ni ne redemarre l'intent-service (manuel).

Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n
apps/web), schemas (list*/update*/retrain*/snippet), types
(TrainingSuggestion*/Correction*/RetrainResultView...).

Front : CorrectionsPage (onglets Suggestions / Corrections brutes,
bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau
snippet, panneau gate F1). Logique pure corrections.ts. i18n
admin.corrections.*. AdminApiClient : 5 methodes.

Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre,
PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape +
409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress
13/13. specs/backend-architecture.md : section tri + retrain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
29432e5bf2 feat(admin): monitoring des microservices + heartbeat du worker LLM
PR 4 du chantier admin. Board de sante temps reel des dependances.

- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
  recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
  "tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
  Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
  ne throw jamais) appele au boot (index.ts), a chaque tick et apres
  chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
  sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
  tech-step-intent-service (/health), et le worker via son heartbeat.
  Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
  ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
  15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
  AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
  job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
  sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
  Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
  apps/admin-web/cypress/{screenshots,videos,downloads}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
3a416ea955 feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent)
PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.

Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
  index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
  PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
  la migration).

Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
  detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
  planning.item_added, tech_step.correction_submitted, shopping_list.viewed.

API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.

Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.

reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:31 +02:00
c7301ea844 feat(admin): scaffold de l'application d'administration apps/admin-web
Nouvelle app Vite/React independante (workspace apps/*), calquee sur
apps/web : port 5174, sa propre image Docker (nginx statique), son propre
domaine/deploiement.

- AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError.
- AdminAuthContext / RequireAdmin : restaure la session admin via
  GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth).
- LoginPage (/login) : validation cliente via adminLoginSchema partage,
  erreurs traduites via ErrorMessageService.
- AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) +
  deconnexion, rendu une fois autour du groupe RequireAdmin.
- Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux
  PR 3-5).
- i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss
  copies de apps/web (extraction en package partage : suivi separe).
- Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA
  fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT,
  VITE_ADMIN_API_URL en build arg).
- Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout
  .cy.ts (redirection /login sans session, nav entre sections, logout).
  Job "Run admin-web E2E tests" ajoute a ci.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:30 +02:00
c3d2ac7e8e feat(admin): fondation auth de l'application d'administration
Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.

- Table AdminUser isolee (aucune relation vers UserProfile), migration
  20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
  propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
  tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
  requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
  /logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
  signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
  + tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
  ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
  ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
  (AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
  ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
  ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
  mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
  dans la reponse), /me derriere requireAdmin, logout, et un cookie
  `session` d'utilisateur normal ne donne pas acces a /admin/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:29 +02:00
e8ac2d1629 feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
Some checks failed
CI / lint (push) Successful in 3m1s
CI / test (push) Failing after 3s
CI / build (push) Successful in 4m24s
CI / intent-service-test (push) Successful in 18m40s
CI / e2e (push) Successful in 13m15s
- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement
  quand la semaine affichee contient >= 1 recette ; navigue vers
  /cuisiner?date=<semaine>.
- Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend
  les phases (mise en place / cuisson / dressage), les taches mutualisees
  avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande
  "Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=,
  WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de
  courses).
- Logique pure extraite dans cooking-session.ts (composition des libelles,
  formatage des quantites).
- i18n : bloc cookingSession.* + planning.startCooking.
- apiClient.getCookingPlanForWeek.
- Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session
  .feature (parcours planning -> /cuisiner), assertions bouton dans
  planning-page.cy.ts ; step generique "button should be disabled" mutualise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
6b60c11408 feat(cooking): endpoint GET /cooking-session (plan de cuisine optimise)
Module cooking-session : charge le Planning couvrant ?date= (meme requete
"plage couvrante" + degradation "jamais null" que /shopping-list), mappe
chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/
unites/techniques/ustensiles resolus via toIngredientView/toUnitView
reutilisees de recipe.service), renvoie OptimizedCookingPlanView.

- cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude.
- Route requireAuth, contrat ?date= identique a /shopping-list.
- Monte /cooking-session dans app.ts.
- Tests d'integration Mocha (401, date invalide, plan vide sans foyer /
  sans planning, mutualisation d'une decoupe entre 2 recettes planifiees).
- specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO
  -> v1 implementee ; nouvelle section dans backend-architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
83a12d474d feat(cooking): moteur d'optimisation des etapes de batch-cooking
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
cc4278ec68 Actualiser apps/web/package.json
Some checks failed
CI / lint (push) Successful in 1m51s
CI / intent-service-test (push) Successful in 9m50s
CI / build (push) Successful in 1m5s
CI / e2e (push) Failing after 1m23s
CI / test (push) Failing after 15m48s
2026-08-27 21:11:10 +02:00
478914787f feat(recipes): fusionne l'edition de la technique et des metadonnees
Deux retours utilisateur distincts, meme cause : le popover de correction
presentait la selection de technique et l'edition des ingredients/
ustensiles comme deux etapes successives (liste plate -> "Valider" pour
la selection, puis un ecran separe pour les metadonnees), et cette liste
de techniques elle-meme n'etait qu'un flot de ~74 boutons sans recherche
ni tri, illisible en pratique.

TechStepCorrectionPopover.tsx : les deux fonctions fusionnent en un seul
ecran — la technique se choisit desormais via le meme CatalogSearchPicker
(recherche + liste filtrée) deja utilise pour les ingredients/ustensiles,
avec le choix courant marque visuellement (nouveau prop `selectedId`), et
les sections Ingredients/Ustensiles restent affichees en permanence a cote
plutot que masquees tant qu'aucune technique n'est choisie. "Valider" reste
desactive tant qu'aucune technique n'est selectionnee.

CatalogSearchPicker.tsx : nouveau prop optionnel `selectedId` pour marquer
visuellement l'item courant dans la liste (utilise par le picker de
technique, pas par les sous-flux ingredient/ustensile qui n'ont pas de
notion de "choix courant").

Tests Cypress (component + e2e) et traductions mis a jour pour ce nouvel
ecran unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:30:59 +02:00
5ab9832131 fix(recipes): pre-selectionne la technique deja detectee dans le correcteur
Ouvrir le popover de correction depuis un highlight existant tombait sur
la liste de choix (avec l'option "Aucune technique ici"), pas sur la vue
metadonnees — pour voir/editer les ingredients/ustensiles deja rattaches
a une technique correctement detectee, il fallait recliquer cette meme
technique dans la liste, sans aucun indice que c'est ce qu'il fallait
faire (rien ne la distingue des autres dans cette liste). Resultat cote
utilisateur : la fonctionnalite de correction/edition de metadonnees
etait techniquement presente mais invisible en pratique.

TechStepCorrectionPopover demarre desormais selectionne sur
`previousTechStepId` quand il est defini (un clic sur un highlight
existant) — droit dans la vue Ingredients/Ustensiles, deja pre-remplie.
"Changer" reste disponible pour rejoindre la liste complete (relabelliser
ou supprimer la correspondance).

Tests Cypress (component + e2e) mis a jour pour ce nouveau point d'entree
par defaut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:13:41 +02:00