Les essais 1-2 de cette branche (nom de conteneur Postgres par run) ont
laissé des conteneurs `postgres-122` / `postgres-125` en marche sur le
runner persistant : leur teardown `if: always()` n'a pas tourné (ce runner
saute les étapes finales après un échec) et rien d'autre ne cible ce
préfixe. Un 2e Postgres inactif affame la RAM du runner → requêtes ~5×
plus lentes → `resetDatabase()` dépasse le timeout Mocha. L'étape « Start
Postgres » retire maintenant tout `postgres-*` en plus du `postgres` nu ;
plus rien ne crée ce préfixe, donc ça ne touche que ces résidus.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Les deux approches précédentes de cette branche ne tenaient pas sur ce
runner :
- Nom de conteneur Postgres par run + `$GITHUB_ENV` (721a1ec) : laissait
tourner le conteneur `postgres` nu de l'ancien ci.yml du run précédent →
deux Postgres sur un runner à court de RAM → I/O DB ~5× plus lent →
`resetDatabase()` dépasse le timeout Mocha de 10 s, cascade. Run 125 a
échoué ainsi *en solo*.
- Verrou `docker network create ci-test-lock` (785d97b) : ce runner
(forgejo-runner v3.5.0) n'exécute PAS l'étape de libération `if:
always()` après l'échec d'une étape → le verrou fuit à chaque run rouge.
Retour au ci.yml de `main` (qui passe 614/614) + une seule étape en plus :
« Wait for any earlier test job » boucle tant qu'un conteneur
`GITEA-ACTIONS-TASK-<id>_..._JOB-test` de task id inférieur au sien tourne.
Le runner retire toujours le conteneur de job (succès comme échec), donc
rien à libérer ; task id le plus bas d'abord → pas d'interblocage.
`DATABASE_URL` et l'étape « Start Postgres » redeviennent identiques à
`main`. `timeout-minutes: 45` conservé.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le run 122 (branche avec l'isolation par conteneur du commit précédent) a
quand même échoué : sa base `postgres-122` était bien isolée et migrée
proprement, mais le run 123 (ancien ci.yml) tournait en parallèle sur le
même runner. Deux jobs `test` concurrents saturent le CPU (chacun entraîne
un modèle spaCy pour son intent-service *et* déroule toute la suite Mocha
DB) : un hook `resetDatabase()` dépasse alors le timeout Mocha de 10 s,
Mocha enchaîne sur le hook suivant pendant que le TRUNCATE + reseed du
précédent tourne encore, et les deux se percutent *dans la base d'un seul
run* (`deadlock detected`, `Unique constraint failed on (key)`,
intent-service affamé → F1 0.000).
L'isolation par base ne suffit donc pas ; il faut « un seul job test à la
fois », ce que `concurrency:` ferait nativement — indisponible ici
(Forgejo 9.0.3). À la place : un verrou via le seul point commun à tous
les jobs du runner, le démon docker. `docker network create ci-test-lock`
échoue si le nom est pris → le premier run le détient, les autres bouclent
jusqu'à libération (file d'attente, pas d'annulation). Libération en étape
`if: always()` gardée par un marqueur `/tmp` (un run qui a renoncé ne doit
pas supprimer le verrou du détenteur) + purge d'un verrou de plus de
~35 min (détenteur tué avant sa libération). `timeout-minutes` passe à 60
pour couvrir l'attente en file. L'isolation `postgres-${GITHUB_RUN_ID}`
reste en défense en profondeur (fenêtre de transition où d'autres branches
sont encore sur l'ancien fichier, sans verrou).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deux jobs `test` qui se chevauchent (push de branche peu après un merge
sur `main`) se connectaient au *même* conteneur `--name postgres` sur le
réseau partagé du runner Forgejo : leurs `prisma migrate deploy` +
`seedReferenceData` (beforeAll racine Mocha) se télescopaient — P2002 sur
`key`, « No pending migrations » à tort, décalage des ids de
`tech_step`/`diet` cassant des assertions sans rapport. Vu 3× en une
session (PR #47, #53, #54), à chaque fois re-déclenché par un commit vide
sur un runner au repos.
`concurrency:` serait le correctif naturel mais ce Forgejo (9.0.3) est
antérieur à son support côté Actions (arrivé en Forgejo v14 / Gitea 1.26,
et workflow-level uniquement même là) : la clé est ignorée silencieusement.
À la place, le conteneur prend un nom unique par run
(`postgres-${GITHUB_RUN_ID}`), poussé dans `DATABASE_URL` via `$GITHUB_ENV`
pour les étapes suivantes — deux runs ne partagent plus jamais de base.
Nettoyage : étape de teardown `if: always()` + purge des conteneurs
abandonnés (bornée par l'âge) au démarrage, pour un job tué net avant son
teardown. `timeout-minutes: 40` borne aussi le cas « job zombie ~1h »
observé cette session (Mocha sans `--exit`).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
Les 5 jobs de la run 109 ont échoué tôt sur « git clone: some refs were not
updated » pendant l'étape de checkout, avant même `pnpm install`/`pnpm lint`.
Rien à voir avec cette branche : `pnpm install --frozen-lockfile`, `pnpm lint`
et `pnpm build` sont verts en local.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
La run #104 a échoué au `beforeAll` racine (`seedReferenceData`, P2002 sur
`key`) parce que la run `main` #103 exécutait son propre job `test` au même
moment sur le conteneur Postgres `--name postgres` partagé. Runner idle,
aucune run concurrente cette fois. Code inchangé — build+lint verts en
local.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Le job `test` de la run #101 a lu un état de base incohérent pendant que
le `seedReferenceData` d'une run `main` concurrente (run #100, restée
bloquée) tournait encore — décalage de +5 sur les ids `tech_step`. Le code
de cette branche est inchangé et vérifié localement (9/9 Mocha, sans DB).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Le job `test` a échoué dans le hook racine `beforeAll` sur un
`prisma.diet.upsert()` → P2002 « Unique constraint failed on (key) », un
symptôme classique de seed concurrent : deux jobs `test` (run 87 sur
`main` post-merge #46, run 88 sur cette branche) démarrés à 3 min
d'intervalle partagent le même conteneur Postgres `--name postgres` sur
l'hôte du runner. Aucun rapport avec ce correctif (100% frontend, vérifié
localement 10/10). `main` a son job `test` vert (run 87).
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>
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>
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>
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>
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>
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>
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>
`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>