Commit graph

106 commits

Author SHA1 Message Date
380f22d988 test: aligne les assertions restantes sur TechStepView { isPlaceholder, displayName } (#130)
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build (push) Successful in 3m8s
CI / intent-service-test (push) Successful in 5m0s
CI / e2e (push) Successful in 10m58s
CI / test (push) Successful in 19m18s
CI / build-android (push) Has been skipped
Deux specs n'avaient pas été mises à jour quand `sources.service` s'est mis
à renvoyer un `techStep` complet et que le titre du catalogue admin est
passé de « Ingrédients hors-catalogue » à « Éléments hors-catalogue » :

- `sources.test.ts` : les deux `deep.equal({ id, key })` sur le techStep
  d'une étape de preview attendent maintenant aussi `isPlaceholder: false`
  et `displayName: null`.
- `admin-layout.cy.ts` : l'assertion du `h1` de /admin/catalogue suit le
  nouveau libellé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:04:57 +02:00
b49ef5bb40 feat(recipes): signaler une technique absente du catalogue (#130)
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00
e8596a55bc feat(recipes): régimes d'une recette calculés depuis ses ingrédients (#131)
All checks were successful
CI / lint (push) Successful in 2m58s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m57s
CI / e2e (push) Successful in 10m45s
CI / test (push) Successful in 18m56s
CI / build-android (push) Has been skipped
`RecipeDiet` (tag manuel) est supprimé — table droppée, `dietIds` retiré de
`createRecipeSchema`, `DietTagSelect` retiré des formulaires de recette
(création + import). `RecipeSummaryView.diets` / `RecipeView.diets` sont
désormais **dérivés** via `computeRecipeDietViews` (`@batch-cooking/shared`,
pur, testé isolément) :

- un régime (vegetarian / vegan / pescatarian) est retenu ssi *tous* les
  ingrédients y sont compatibles (`IngredientDiet`, exposé par
  `IngredientView.diets`) ;
- `glutenFree` est dérivé des allergènes (aucun ingrédient ne porte
  `gluten`) — cohérent avec le fait que le seed ne le stocke pas dans
  `IngredientDiet` ;
- `[]` dès qu'une recette contient un ingrédient placeholder (hors-catalogue) :
  un ingrédient inconnu ne certifie rien.

Filtres du sélecteur de planning (`suitableForHousehold`, `dietIds`) : ne
peuvent plus être du SQL sur une colonne stockée — devenus des post-filtres
sur `diets` calculé dans `listRecipes` ; `omnivore` traité comme « aucune
contrainte ». `assertDietsExist` / `ErrorCode.DIET_NOT_FOUND` sur la création
recette supprimés (le code d'erreur reste, utilisé par la préférence profil).

`DietBadges` inchangé (affiche `diets` tel quel). `RecipePickerDialog` garde
son filtre régime (fonctionne via le post-filtre).

Tests : `recipe-diets.test.ts` (fonction pure) + `recipe.test.ts` réécrit
(régimes calculés attendus, tests DIET_NOT_FOUND retirés) ; Cypress OK
(formulaire sans sélecteur de régime, régression recettes/sources/planning).
Migration `DROP TABLE recipe_diet` — aucune donnée à conserver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:00:52 +02:00
ba030ec18c feat(recipes): ingrédients d'assaisonnement « selon le goût » sans quantité (#128)
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m12s
CI / e2e (push) Successful in 11m1s
CI / test (push) Successful in 19m13s
CI / build-android (push) Has been skipped
`RecipeIngredient.quantity`/`unitId` deviennent nullable (migration purement
additive) : une ligne de recette peut n'avoir ni quantité ni unité — sel,
poivre… ajoutés selon le goût. Le schéma partagé impose « quantité et unité,
ou aucune des deux » ; `toRecipeView` renvoie `null`/`null`.

`aggregateShoppingList` fait apparaître une telle ligne une seule fois, sans
quantité ni unité, jamais sommée ni mise à l'échelle. `ShoppingListPage`
affiche « selon le goût » à la place de la quantité.

Formulaire : `IngredientRow` gagne une case « Selon le goût » qui masque
quantité/unité ; `RecipeFormPage` et `RecipeImportForm` n'exigent plus
quantité/unité pour ces lignes et les envoient sans. Un import dont la source
laisse une ligne résolue sans quantité (« salt to taste ») arrive
directement en « selon le goût ».

Le chemin clause-de-technique (session de cuisine) tolérait déjà `quantity`
null — inchangé.

Tests : Mocha (création/édition d'une ligne sans quantité, rejet
quantité-sans-unité, liste de courses non sommée) ; Cypress (case à cocher +
parcours), régression recette/liste de courses OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:15:28 +02:00
8ae06f10c9 feat(recipes): barre Enregistrer/Annuler collée en pied du formulaire
All checks were successful
CI / lint (push) Successful in 56s
CI / build (push) Successful in 1m29s
CI / intent-service-test (push) Successful in 3m53s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 17m27s
CI / build-android (push) Has been skipped
`.recipe-form__actions` gagne un modificateur `--sticky`
(`position: sticky; bottom: 0`) : sur une recette longue (beaucoup
d'ingrédients/étapes) la barre reste visible sans redescendre. Ajoute un
bouton « Annuler » secondaire qui revient à la recette éditée
(`/recettes/:id`) ou au catalogue (`/recettes`) pour une création — sans
garde « abandonner les modifications ? », cohérent avec les autres
annulations de l'app (seule la suppression, irréversible, est confirmée).

Tests Cypress : la barre reste ancrée en bas après défilement vers le haut
d'un formulaire plus haut que la fenêtre ; « Annuler » navigue correctement
en création comme en édition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:42:14 +02:00
30f975d29a feat(admin): crée un ticket Forgejo depuis un message de contact (#81)
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build (push) Successful in 3m29s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 10m24s
CI / test (push) Successful in 18m48s
CI / build-android (push) Has been skipped
Depuis /admin/messages, un admin peut créer une issue Forgejo à partir
d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie
éditable → l'issue est créée via l'API Forgejo et le lien (#N) est
mémorisé sur le message. Première intégration Forgejo d'apps/api.

- Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage`
  (migration `20260908130000_contact_message_forgejo_link`, 2 colonnes
  nullables, aucun backfill).
- Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes
  `.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) :
  bouton masqué + endpoint `503` tant que les trois ne sont pas là.
  Documentées dans `.env.example` ; valeurs de test dans `ci.yml`.
- `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) :
  `isConfigured()` + `createIssue({ title, body })` → `POST .../issues`,
  en-tête `Authorization: token <pat>`, lit `{ number, html_url }` ;
  erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502
  FORGEJO_REQUEST_FAILED`.
- `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue`
  (`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo →
  écriture BDD ; un échec Forgejo ne touche pas la BDD) et
  `GET /admin/api/contact/forgejo/status`.
- Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000),
  `ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux
  `ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002,
  `CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055)
  + labels i18n.
- Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne
  « Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon),
  `CreateForgejoIssueModal` + helpers purs `messages.ts`
  (`buildIssueTitle` / `buildIssueBody`).
- Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`),
  `admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 +
  ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie,
  lien après création, masquage si non configuré).
- Docs : `backend-architecture.md`, `batch-cooking-modele.md`.

Closes #81.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 20:10:54 +02:00
0c8cda7f3e feat(admin): consultation des messages de contact (#80)
All checks were successful
CI / lint (push) Successful in 4m17s
CI / build (push) Successful in 5m11s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 11m26s
CI / test (push) Successful in 29m58s
CI / build-android (push) Has been skipped
PR 2/2 de l'issue #80 — la vue admin en lecture seule des messages
remontés via le formulaire de contact (PR 1 = #123).

- Contrat partagé : `listContactMessagesQuerySchema` (filtre `type`
  optionnel) + `types/contact.ts` `ContactMessageAdminView` (`author` =
  `{ id, firstName, lastName, email } | null`, `null` = compte supprimé).
- API : `admin-contact.{routes,service}.ts` — `GET /admin/api/contact?type=`
  (`requireAdmin`), tri `createdAt desc`, plafond `CONTACT_MESSAGES_PAGE_SIZE
  = 200` (même esprit que `CORRECTIONS_PAGE_SIZE`). Branché dans
  `admin.routes.ts` (`adminRouter.use(/contact, …)`). Aucune route
  d'écriture.
- Front : page `/admin/messages` (`MessagesPage` + `.scss`) — tableau
  lecture seule (date, expéditeur ou « Compte supprimé », badge de type,
  message, page, user-agent tronqué + titre au survol), un filtre par type.
  Entrée de nav « Messages » (icône `Inbox`) dans `AdminLayout` +
  `adminApiClient.getContactMessages` + i18n `admin.nav.messages` /
  `admin.messages.*`.
- Tests : `apps/api/test/admin-contact.test.ts` (Mocha — 401 sans session
  admin, tri récent d'abord + `author` résolu, filtre `type`, type inconnu →
  400, plafond 200, `author: null` après suppression du compte),
  `apps/web/cypress/e2e/admin-messages.cy.ts` (rendu lecture seule, filtre
  par type via le query param).
- Docs : `backend-architecture.md` (section `admin` + event
  `contact.submitted` dans la liste).

Closes #80.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:17:45 +02:00
a252640fe6 feat(contact): modèle + POST /contact + formulaire utilisateur (#80)
Some checks failed
CI / build (push) Successful in 2m22s
CI / lint (push) Successful in 2m26s
CI / intent-service-test (push) Successful in 4m8s
CI / e2e (push) Successful in 12m40s
CI / test (push) Failing after 15m4s
CI / build-android (push) Has been skipped
Formulaire in-app de remontée de bug / suggestion pour les utilisateurs
connectés (PR 1/2 de l'issue #80).

- Modèle Prisma `ContactMessage` (+ relation inverse sur `UserProfile`) et
  migration `20260908120000_contact_message` — table neuve, aucun backfill.
  `authorId` nullable + `onDelete: SetNull` : un signalement survit à la
  suppression de son compte.
- Contrat partagé `schemas/contact.ts` : `CONTACT_MESSAGE_TYPES`,
  `submitContactMessageSchema` (type enum, message 10-5000 trim, pageUrl /
  appVersion optionnels — jamais le user-agent).
- Module API `contact` : `POST /contact` (`requireAuth`, monté `/contact`),
  `204` sans corps. Le user-agent est lu de l'en-tête de requête, jamais du
  corps. Émet `analytics.recordEvent(contact.submitted, …)`.
- Page `/parametres/contact` (`ContactPage` + `contact.ts` + `.scss`) :
  select de type, textarea, validation cliente via le schéma partagé, états
  succès / erreur (`ErrorMessageService`, jamais `err.message`). Entrée de
  nav « Nous contacter » dans `SETTINGS_ITEMS` + clé i18n + icône.
  `apiClient.submitContactMessage(input)` ajoute `pageUrl` / `appVersion`.
- Tests : `apps/api/test/contact.test.ts` (Mocha — insertion, en-tête UA,
  champs nullables, message trop court / trop long, type inconnu),
  `contact.feature` + steps (Cypress e2e — envoi + confirmation + contexte),
  `contact-page.cy.ts` (rendu + garde `RequireAuth` + label d'erreur).
- Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
  `frontend-architecture.md`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:26:47 +02:00
592933b1ac feat(shopping-list): coche les articles « dans le panier » (#109)
All checks were successful
CI / lint (push) Successful in 2m18s
CI / build (push) Successful in 3m34s
CI / intent-service-test (push) Successful in 6m36s
CI / e2e (push) Successful in 10m22s
CI / test (push) Successful in 33m30s
CI / build-android (push) Has been skipped
La liste de courses était volontairement en lecture seule. Elle gagne un
état « acheté » par article, utile en magasin (au doigt sur mobile).

- `shopping-list-progress.ts` (nouveau) : `ShoppingListProgressStore`,
  calqué sur `CookingProgressStore` — `localStorage` uniquement, par
  semaine (clé `batchcooking:shoppingListChecked:<weekStart>`), aucun état
  serveur. Une coche n'est jamais autoritaire (la liste est recalculée
  depuis le planning à chaque visite) et se perd sans dommage.
- `ShoppingListPage.tsx` : chaque ligne devient un `CheckboxOption` ;
  `<ShoppingListItems key={weekKey}>` remonte à chaque changement de
  semaine (checklist réinitialisée). Clé d'article = `ingredientId-unitId`
  (déjà la `key` React de la ligne).
- `shopping-list-page.scss` : override scopé du look « carte sélectionnable »
  global — un article coché est barré et estompé, pas surligné en primary.
  Cible tactile ≥ 44 px sous le breakpoint phone.
- `specs/frontend-architecture.md` : la note « délibérément en lecture
  seule » est remplacée.

Tests : `shopping-list.feature` — nouveau scénario « cocher un article »
(coché → barré, persiste au reload, n'affecte pas les autres).
`shopping-list-page.cy.ts` — la cible tactile de la ligne (le label
`CheckboxOption`) fait 16 px de padding + ≥ 44 px de haut sur téléphone.
Local : `pnpm lint` + `pnpm --filter web build` OK, `shopping-list.feature`
4/4, `shopping-list-page.cy.ts` 1/1.

Closes #109

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 06:31:37 +02:00
8bcea74b56 fix(layout): masque l'onglet « Paramètres d'application » hors de l'app native (#107)
All checks were successful
CI / lint (push) Successful in 4m25s
CI / build (push) Successful in 2m18s
CI / intent-service-test (push) Successful in 5m41s
CI / e2e (push) Successful in 8m25s
CI / test (push) Successful in 30m0s
CI / build-android (push) Has been skipped
Ses sections n'ont pas de sens sur le web (pas de « version installée », la
vérif de mise à jour y est un no-op) et son seul réglage web-pertinent — la
case « toujours rester sur le site web » — vit mieux dans le flux qu'il
pilote.

- `AppLayout.tsx` : `SETTINGS_ITEMS` gagne un `nativeOnly`, filtré une fois
  au chargement du module (`settingsNavItems`) via `Capacitor.isNativePlatform()`
  — l'entrée disparaît du menu Paramètres (sidebar + feuille « Plus »).
- `App.tsx` : la route `/parametres/application` redirige vers
  `/parametres/compte` sur le web, comme le précédent `/foyer`, pour qu'un
  bookmark ne tombe pas sur une page orpheline.
- `AppSettingsPage.tsx` : devient natif-only — la branche `webHint` et la
  section « rester sur le site web » sont retirées.
- La case « toujours rester sur le site web » déménage sur `/telecharger`
  (`DownloadPage`) : page publique qu'un visiteur non connecté sur
  navigateur téléphone peut atteindre, et c'est exactement le flux que la
  préférence concerne. `localStorage` sur l'origine web publique, là où
  `attemptOpenNativeApp` la lit. i18n `appSettings.redirect.*` →
  `download.stayOnWeb.*` ; `appSettings.version.webHint` supprimée.

Tests : `sidebar.cy.ts` / `app-settings-page.cy.ts` — l'entrée et la page
sont absentes sur le web, la route redirige. `download-page.cy.ts` — la
case « rester sur le site web » par défaut décochée, persistée au reload.
Local OK (sidebar 5/5, app-settings 2/2, download 3/3).

Closes #107

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:23:06 +02:00
e702e21296 fix(layout): barre de navigation mobile en icônes seules (#105)
All checks were successful
CI / lint (push) Successful in 4m37s
CI / build (push) Successful in 3m17s
CI / intent-service-test (push) Successful in 5m34s
CI / e2e (push) Successful in 8m59s
CI / test (push) Successful in 30m12s
CI / build-android (push) Has been skipped
Sur téléphone, l'onglet « Liste de courses » (16 caractères) passait sur 2
lignes dans un onglet à ~90px alors que « Planning »/« Recettes »/« Plus »
tenaient sur 1 ligne : son bloc icône+libellé centré était plus haut, donc
son icône visiblement plus haute que les autres.

`AppLayout.scss` `.app-bottom-nav` : le `.label` est masqué visuellement (à
la manière du rail desktop replié — `position: absolute; clip; …`), pas
retiré du DOM, donc chaque `<NavLink>`/bouton garde son nom accessible (un
lecteur d'écran annonce toujours « Planning », « Liste de courses »…) sans
`aria-label` qui dupliquerait la chaîne i18n. Icônes légèrement agrandies
(1.375rem → 1.5rem) puisqu'elles sont désormais seules.

Tests : `layout.cy.ts` — l'onglet expose son `span.label` dans le DOM mais
`not.be.visible`, l'icône `svg` visible (mise à jour de l'assertion qui
attendait le libellé visible). `sidebar.cy.ts` / `planning-page.cy.ts`
(nav desktop) inchangés. Suite layout/sidebar/planning revérifiée, 27/27.

Closes #105

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:56 +02:00
a54cb85027 fix(auth): déconnexion forcée quand une requête 401 en cours de session (#108)
All checks were successful
CI / lint (push) Successful in 2m37s
CI / build (push) Successful in 3m37s
CI / intent-service-test (push) Successful in 5m20s
CI / e2e (push) Successful in 9m52s
CI / test (push) Successful in 28m39s
CI / build-android (push) Has been skipped
La session expire déjà (JWT 7 j), mais le client web n'y réagissait pas :
seul le `GET /auth/me` du montage de `AuthProvider` attrapait un 401 ; un
401 de n'importe quel autre endpoint ne faisait que remonter une erreur
locale, l'UI connectée restant affichée jusqu'au prochain rechargement
complet. Sur l'app native (rarement redémarrée, token persisté) la session
paraissait ne jamais finir.

- `client.ts` : sur un `401 NOT_AUTHENTICATED` pour toute requête autre que
  le probe `GET /auth/me`, `ApiClient._request` purge le token store et
  appelle un handler `onSessionExpired` (enregistré via
  `setSessionExpiredHandler`). L'`ApiError` continue d'être levée comme
  avant.
- `AuthContext.tsx` : `AuthProvider` enregistre ce handler au montage —
  `setUser(null)` + `navigate(/login, { replace: true })`, gardé par un
  `userRef` pour être un no-op si déjà déconnecté. `ApiClient` restant
  agnostique du framework, c'est bien lui qui délègue la navigation/état.

Pas de changement API : le back renvoie déjà 401 sur JWT expiré
(`require-auth.ts`), couvert par sa suite Mocha.

Tests : `apps/web/cypress/e2e/session-expiry.cy.ts` — un 401 en cours de
session redirige vers `/login` et vide l'état (un retour sur une route
gardée re-redirige) ; le 401 du `/auth/me` initial d'un visiteur non
connecté ne déclenche pas ce chemin (la landing reste affichée). Suite e2e
existante (auth / layout / account / recipes / onboarding / preferences)
revérifiée sans régression.

Closes #108

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:41:46 +02:00
b8cbf2d777 fix(test): fige l'horloge dans le spec vue-mobile-jour de layout.cy.ts
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build (push) Successful in 3m3s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 9m47s
CI / test (push) Successful in 17m22s
CI / build-android (push) Has been skipped
`PlanningDayView` ouvre sur le jour *courant* de la semaine affichée
(`defaultDayIndexForWeek`, dérivé de `Date`). Le test « shows the one-day
mobile view » ne figeait pas l'horloge : il ne passait que quand la CI
tournait un lundi (UTC), et échouait les autres jours (`Curry de
lentilles`, moqué sur « lundi », hors du jour affiché).

`cy.clock(new Date(2026-08-17T09:00:00.000Z), [Date])` avant le
`cy.visit` — même lundi de référence que `planning-page.cy.ts` ; `[Date]`
seulement, les timers/debounces tournent toujours.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:46:44 +02:00
c4c2c2ae39 fix(planning): la grille remplit l'écran, une case ne grandit plus (#110)
All checks were successful
CI / lint (push) Successful in 2m23s
CI / build (push) Successful in 5m41s
CI / intent-service-test (push) Successful in 7m13s
CI / e2e (push) Successful in 10m50s
CI / test (push) Successful in 29m0s
CI / build-android (push) Has been skipped
`table.planning-grid` a `height: 100%` mais des hauteurs de ligne pilotées
par le contenu : un créneau avec plusieurs recettes étirait son `<td>`,
donc toute la ligne (les autres cases gagnant la même hauteur morte sous
`vertical-align: top`), puis la table débordait de `.planning-grid-wrapper`
en scroll vertical.

- `tbody tr { height: 1px }` : les cinq lignes se partagent la hauteur du
  wrapper à parts égales (une hauteur de ligne est un minimum que le
  navigateur étire pour absorber la hauteur restante de la table), au lieu
  d'être aussi hautes que la colonne la plus chargée.
- `.meal-cell__recipes` : `max-height: 8.5rem; overflow-y: auto` — au-delà
  d'une poignée de recettes la liste scrolle dans la case. Cap en `rem` et
  pas en `%` : les hauteurs en pourcentage sur un `<td>` ne se résolvent
  pas de façon fiable selon le moteur. `.add-recipe-btn` (frère flex hors
  de cette boîte) reste épinglé juste en dessous.
- `min-width: 62rem` laissé tel quel : les 7 colonnes ont besoin d'un
  plancher pour rester lisibles, et le scroll horizontal dessous est
  couvert par `layout.cy.ts`. Le breakpoint phone (vue `PlanningDayView`)
  n'est pas concerné.

Closes #110

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:36:21 +02:00
642b37ebec fix(planning): affiche les recettes du picker sur mobile (#111)
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build (push) Successful in 1m43s
CI / intent-service-test (push) Successful in 3m56s
CI / e2e (push) Successful in 8m37s
CI / test (push) Successful in 16m12s
CI / build-android (push) Has been skipped
Sur téléphone, le dialogue d'ajout d'une recette au planning devenait
plein écran mais `.recipe-picker-dialog` gardait un `height: 80vh` figé.
Dans cette colonne flex de hauteur bornée, le bloc `.recipe-picker__filters`
(rendu pour les 4 onglets non-source) consommait toute la place et
`.recipes-page__catalog` (`flex: 1`) s'effondrait à ~0 : la table des
recettes était dans le DOM mais dans un scrollport de hauteur nulle, donc
les onglets Favoris / Perso / Foyer / Publique paraissaient vides.

Sous le breakpoint phone : le dialogue passe vraiment plein écran (comme
tout autre `Dialog`), `.recipe-picker__filters` ne se laisse plus
compresser, et le catalogue prend sa hauteur de contenu — c'est
`.dialog-panel__body` qui scrolle.

Closes #111

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:18:17 +02:00
4f553147ed Merge pull request '[UI mobile] La landing page met en avant le téléchargement sur mobile' (#101) from feat/mobile-landing-download into main
Some checks failed
CI / lint (push) Successful in 2m50s
CI / build (push) Successful in 2m53s
CI / intent-service-test (push) Successful in 4m37s
CI / e2e (push) Successful in 9m42s
CI / test (push) Successful in 17m4s
CI / build-android (push) Has been cancelled
2026-09-07 17:40:20 +02:00
c82f0969a8 Merge pull request '[UI mobile] Onglet "Paramètres d'application"' (#100) from feat/mobile-app-settings into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:40:02 +02:00
8d1c4a7f0b Merge pull request '[UI mobile] Liste de courses et catalogue de recettes : renforcement mobile' (#99) from feat/mobile-shopping-recipes into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:39:53 +02:00
f0f5e9f344 Merge pull request '[UI mobile] Session de cuisine : passe mobile' (#98) from feat/mobile-cooking-session into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / build-android (push) Blocked by required conditions
2026-09-07 17:39:42 +02:00
615e320be1 feat(landing): met en avant le téléchargement/ouverture de l'appli sur mobile (#85)
Some checks failed
CI / lint (push) Successful in 2m56s
CI / build (push) Successful in 3m6s
CI / intent-service-test (push) Successful in 4m42s
CI / build-android (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / test (push) Has been cancelled
Sur un vrai navigateur mobile Android (isAndroidPhoneBrowser(), déjà
utilisé par le ticket #84), la ligne de CTA de la landing page affiche
désormais "Ouvrir dans l'application" comme action PRIMAIRE — plus
utile qu'un compte web puisque l'expérience web n'est de toute façon
pas optimisée mobile (au cœur de ce chantier), et un visiteur avec
l'appli déjà installée peut s'inscrire/se connecter depuis l'appli
elle-même. "Créer un compte"/"Se connecter" sont rétrogradés en
actions secondaires plutôt que masqués : certains visiteurs préfèrent
sciemment le web.

Le clic déclenche attemptOpenNativeApp (tentative d'ouverture du
scheme, repli automatique après le délai) plutôt que d'afficher un
second lien séparé — remplace .landing__mobile-link, ne le duplique
pas. Sur desktop, comportement inchangé (CTA "Créer un compte" toujours
primaire) sauf le lien Android discret, qui pointe maintenant vers
/telecharger (page de repli du ticket #84) au lieu du lien direct
/mobile/download — un seul point d'entrée "obtenir l'appli" pour tout
le site plutôt que deux mécanismes légèrement différents à maintenir
en synchro.

Couverture Cypress : landing.feature étendu avec un nouveau scénario
mobile (UA Android spoofée via onBeforeLoad, même chaîne que le test
unitaire existant d'isAndroidPhoneBrowser dans
mobile-app-redirect.cy.tsx) — vérifie l'affichage du bouton et la
rétrogradation de Créer un compte/Se connecter, SANS jamais cliquer le
bouton réel (son onClick appellerait un vrai window.location.href vers
un scheme personnalisé, qui bloque indéfiniment le runner Cypress/
Electron — piège déjà documenté, la fonction elle-même est déjà
testée unitairement avec un stub injecté). Le scénario desktop existant
est mis à jour pour le nouveau href /telecharger. Suite complète :
biome check, tsc -b --force, build, 145/145 e2e, 71/71 composants —
tous verts.

Closes #85

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:34:44 +02:00
e1589e63d0 feat(settings): onglet "Paramètres d'application" (#86)
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m56s
CI / intent-service-test (push) Successful in 4m44s
CI / e2e (push) Successful in 9m40s
CI / test (push) Failing after 17m34s
CI / build-android (push) Has been skipped
Nouvelle route /parametres/application + entrée dans SETTINGS_ITEMS
(desktop et sheet mobile, un seul tableau partagé). Thème et "rejouer
un tutoriel" restent inchangés dans /parametres/preferences-utilisateur
(décision explicite du chantier).

Le contenu affiché diffère réellement selon la plateforme, pas par
simple choix cosmétique — chaque moitié de la page est inerte sur
l'autre plateforme, pas juste hors-sujet :

- Version installée + "Vérifier les mises à jour" (natif seulement) :
  useAppUpdateCheck (déjà utilisé par la bannière silencieuse
  existante) gagne un checkNow() qui relance la même comparaison sans
  attendre le throttle de 6h, réutilisant la logique existante plutôt
  que de la dupliquer (fetchLatestVersionResult extraite en fonction
  partagée). Nouveau hook useInstalledVersion (App.getInfo() natif,
  __APP_VERSION__ web).

- "Toujours rester sur le site web" (web seulement, tout appareil) :
  pilote STAY_ON_WEB_PREFERENCE_KEY (déjà exporté par
  mobile-app-redirect.ts, ticket #84). Volontairement masqué côté natif
  : le WebView Capacitor tourne sur sa propre origine https://localhost,
  un localStorage totalement séparé de celui du vrai site web que lit
  attemptOpenNativeApp — modifier la préférence depuis l'app ne
  changerait donc jamais rien pour un visiteur mobile réel. Documenté
  explicitement dans le commentaire de la page pour ne pas réintroduire
  ce contrôle-fantôme plus tard.

Couverture Cypress : nouveau app-settings-page.cy.ts (contenu web,
persistance du toggle après reload) ; sidebar.cy.ts étendu pour couvrir
le nouveau lien. Le déclenchement natif réel (App.getInfo(), l'appel
réseau de checkNow) n'a pas de point d'entrée testable depuis un run
Cypress web — même posture déjà acceptée pour useAppUpdateCheck/
UpdateBanner, la logique de comparaison pure reste couverte par
version-compare.cy.tsx (inchangé). Suite complète : biome check,
tsc -b --force, build, 146/146 e2e, 71/71 composants — tous verts.

Closes #86

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:19:36 +02:00
7d1a69cf12 fix(mobile): renforce le catalogue de recettes et la liste de courses sur mobile (#90)
Some checks failed
CI / lint (push) Successful in 3m2s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 9m38s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
Deux correctifs, regroupés en un seul ticket comme prévu :

- Catalogue de recettes (`recipes.scss`) : `.recipe-table-wrap` n'avait
  aucun garde-fou de scroll horizontal, contrairement à la grille
  planning — sur un viewport étroit, les 4 colonnes (photo/nom/
  allergènes/régimes) se compressaient au lieu de scroller. Ajout
  d'`overflow-x: auto` sur le wrapper + `min-width: 36rem` sur la
  table (échelle réduite par rapport aux 62rem de la grille planning :
  4 colonnes plus étroites, pas 7). Partagé par le catalogue complet
  ET par `RecipePickerDialog`, qui hérite du même correctif au passage.

- Liste de courses (`shopping-list-page.scss`) : audit dédié — la page
  n'a en réalité aucune case à cocher ni bouton de suppression (elle
  est volontairement en lecture seule, voir le commentaire de
  ShoppingListPage.tsx), contrairement à ce que supposait le ticket
  d'origine ; rien à agrandir comme cible tactile de ce côté. La
  structure ne déborde déjà pas non plus (`&-name` a déjà flex:1/
  min-width:0). Amélioration retenue à la place : une ligne un peu
  plus aérée et une icône plus grande sous le breakpoint phone, pour
  une lecture plus confortable en faisant les courses (liste tenue
  d'une main, coups d'œil répétés).

Couverture Cypress : nouveau describe "Recipe catalog (phone)" dans
recipes.cy.ts (garde-fou de scroll horizontal effectivement actif) ;
nouveau fichier shopping-list-page.cy.ts, spec layout simple (pas
Gherkin, cette page n'a pas encore ce type de fichier) pour le padding
de ligne. Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants (un skip ponctuel de TechStepCorrectionPopover
en run groupé, reconfirmé 10/10 isolément — flake local sans lien avec
ce ticket) — tous verts.

Closes #90

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:02:48 +02:00
2b5ae6722a fix(cooking-session): premier passage responsive dédié à l'écran de cuisine (#89)
Some checks failed
CI / build (push) Successful in 2m35s
CI / lint (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 5m8s
CI / e2e (push) Successful in 9m17s
CI / test (push) Failing after 16m14s
CI / build-android (push) Has been skipped
cooking-session-page.scss n'avait encore aucune règle @media. Audit
dédié à cet écran (minuteur inexistant sur cette page — rien à faire
de ce côté, liste d'étapes, actions) :

- Bug d'overflow réel dans l'onglet Recettes : `.cooking-recipes__name`
  n'avait aucune borne dans sa ligne flex (contrairement au
  `.recipe-chip__name` équivalent côté planning) — un nom de recette
  long pouvait repousser le badge de portions et le chevron hors de la
  case (silencieusement coupés par le `overflow: hidden` de la carte
  parente). Corrigé avec le même traitement flex:1/min-width:0/
  ellipsis que son équivalent.

- Cibles tactiles sous le breakpoint phone : les cartes de tâches (`.
  cooking-task`, l'action principale et répétée de tout l'onglet Plan),
  les nœuds du stepper, les onglets, la ligne de l'onglet Recettes et
  les boutons Précédent/Suivant passent tous à --space-md pour dépasser
  confortablement les 48dp minimums d'Android (l'app est Android-only)
  même dans le pire cas (une tâche à une seule ligne, sans ustensiles).

- Piège rencontré : surcharger `.cooking-task` par un simple sélecteur
  de classe ne fonctionnait pas silencieusement — la règle globale
  partagée `label:has(> input[type="checkbox"])` (global.scss) a une
  spécificité CSS plus élevée qu'il n'y paraît (l'argument de :has()
  compte comme un élément + un attribut). Corrigé en réutilisant le
  même niveau d'imbrication que `.cooking-phase__tasks .cooking-task
  --done` juste au-dessus dans le même fichier, qui contournait déjà
  ce problème pour la même raison.

Couverture Cypress : nouveau describe "Cooking session page (phone)"
dans cooking-session-page.cy.ts (2 tests — padding effectivement
appliqué + troncature du nom de recette sans pousser le badge hors
champ). Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants — tous verts.

Closes #89

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:49:08 +02:00
964269d3f3 feat(planning): vue mobile dédiée "un jour à la fois" pour la grille (#88)
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 4m19s
CI / test (push) Failing after 4m23s
CI / e2e (push) Successful in 8m41s
CI / build-android (push) Has been skipped
La grille planning (7 colonnes × 5 repas, min-width: 62rem) était le
principal écran inaccessible sur téléphone : sous ce seuil elle ne se
réorganisait pas, elle scrollait juste horizontalement — un tableau
desktop rétréci, pas un vrai écran mobile.

Sous le breakpoint phone (useIsMobileViewport()), PlanningPage bascule
désormais entièrement sur PlanningDayView : un sélecteur de jour (jour
précédent/suivant, borné au lundi/dimanche de la semaine affichée, pas
de bouclage vers la semaine adjacente — ça reste le rôle des flèches du
WeekNavigator) suivi des 5 repas du jour sélectionné, empilés en
cartes.

Le contenu de chaque case (pastilles de recettes + bouton "+") est
mutualisé entre les deux layouts via une fonction non-hook partagée,
mealCellBody, appelée aussi bien par MealCell (<td>, grille desktop)
que par le nouveau MealCellRow (<div>, vue jour) — même logique de
drop-target @dnd-kit (useDroppable) des deux côtés, donc le glisser-
déposer d'un reste continue de fonctionner à l'identique sur mobile.

Mise à jour de layout.cy.ts : son test #39 "la grille reste scrollable
horizontalement sur phone" décrivait un comportement désormais remplacé
par ce ticket — réécrit pour vérifier la bascule vers la vue jour au
lieu du scroll horizontal.

Closes #88

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:27:46 +02:00
23bc19b482 Merge pull request 'test(cypress): convention de viewports téléphone/tablette' (#94) from feat/mobile-cypress-viewports into main
Some checks failed
CI / intent-service-test (push) Failing after 1m32s
CI / lint (push) Successful in 2m11s
CI / build (push) Successful in 2m18s
CI / e2e (push) Successful in 9m2s
CI / test (push) Successful in 16m40s
CI / build-android (push) Has been skipped
2026-09-07 16:02:24 +02:00
0ff94c61ca Merge pull request 'fix(planning): popovers flottants sûrs sur mobile' (#95) from feat/mobile-popovers-safe into main
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m26s
CI / build (push) Successful in 3m30s
CI / intent-service-test (push) Successful in 5m31s
CI / e2e (push) Successful in 9m45s
CI / test (push) Has been cancelled
2026-09-07 15:46:09 +02:00
0447c68b69 merge: rapatrie le correctif de serialisation CI (#96)
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build (push) Successful in 4m3s
CI / intent-service-test (push) Successful in 6m48s
CI / e2e (push) Successful in 13m34s
CI / test (push) Successful in 30m44s
CI / build-android (push) Has been skipped
2026-09-07 14:31:05 +02:00
a3701acae4 fix(planning): popovers flottants surs sur mobile
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
Ticket #87 du chantier UI mobile & tablette. LeftoversBasket et
WeekNavigator's CalendarPopover etaient tous les deux des popovers a
largeur fixe (18-20rem), ancres right: 0, sans aucune gestion de bord
d'ecran - sur un telephone etroit ils pouvaient deborder hors du
viewport. Les deux sont declenches depuis l'en-tete du planning, un
ecran central de l'usage quotidien - tres probablement la cause
concrete derriere le retour terrain "je n'ai pas acces a tous les
boutons" sur mobile qui a lance ce chantier.

Les deux basculent desormais sur BottomSheet (ticket #91) sous le
breakpoint phone via useIsMobileViewport() - meme contenu, juste une
autre enveloppe (le popover flottant + son ecouteur de clic exterieur
manuel restent inchanges sur desktop). Le clic-exterieur devient un
no-op propre sur mobile plutot qu'un ecouteur qui ne fait jamais rien
(le ref ne s'attache a rien dans la branche BottomSheet).

Nouvelle cle i18n common.calendar.title ("Choisir une semaine") pour
le titre de la feuille - pas de raccourci en decoupant une autre cle
interpolee.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (142/142, 2 nouveaux tests phone dans
leftovers-basket.cy.ts et planning-page.cy.ts) et composants (63/63).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:48:39 +02:00
967be6df4a test(cypress): convention de viewports telephone/tablette
Some checks failed
CI / lint (push) Successful in 3m31s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m51s
CI / test (push) Failing after 12m11s
CI / e2e (push) Successful in 10m50s
CI / build-android (push) Has been skipped
Ticket #83 du chantier UI mobile & tablette. Le repo n'avait aucun
override de viewportWidth/Height ni pour e2e ni pour les tests de
composants - tout tournait a la taille par defaut, jamais un vrai
telephone/une vraie tablette.

- cypress/support/viewports.ts : PHONE_VIEWPORT (390x844) et
  TABLET_VIEWPORT (900x1200), tailles Android reelles plutot que les
  presets iPhone/iPad de Cypress (appli Android uniquement) - dans les
  bandes de _breakpoints.scss ($phone/$tablet du ticket #91), pas
  collees au pixel de bordure. Commande cy.setViewport("phone" |
  "tablet") (pas de surcharge du cy.viewport natif). Importe par
  support/e2e.ts et support/component.ts, disponible dans les deux
  modes.
- layout.cy.ts : les cy.viewport(...) en dur pour du telephone (deux
  tests ajoutes par #91, plus les deux plus anciens du passage #39) et
  le test "au-dessus du point de rupture telephone" migrent vers la
  commande partagee - ce dernier passe a un vrai viewport tablette
  (900px, dans la bande 641-1024) plutot qu'un 1000x700 arbitraire,
  prouvant au passage que le preset tablette fonctionne.
- Nouveau cypress/component/Dialog.cy.tsx : premier test de Dialog en
  isolation (jusqu'ici seulement couvert indirectement via
  RecipePickerDialog) - verifie le plein-ecran sur telephone du ticket
  #91, qui n'avait pas encore son propre test, et prouve au passage que
  cy.setViewport fonctionne en mode composant.

Trouvaille en verifiant : le mode composant de Cypress a son propre
viewport par defaut, plus petit que celui du mode e2e (1000x660) - deja
sous le seuil telephone. Le premier essai du test "desktop" de Dialog
echouait pour cette raison (le CSS plein-ecran s'appliquait sans meme
appeler setViewport). Corrige en fixant un viewport large explicite
pour ce cas plutot que de compter sur un defaut implicite - note ajoutee
a dev-environment-gotchas pour la prochaine fois.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (140/140) et composants (65/65, incluant les 2
nouveaux tests de Dialog.cy.tsx).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:32:36 +02:00
d6cf4aa63d feat(mobile): redirection vers l'appli + page de telechargement
Some checks failed
CI / lint (push) Successful in 3m35s
CI / build (push) Successful in 4m33s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 15m5s
CI / test (push) Failing after 15m28s
CI / build-android (push) Has been skipped
Ticket #84 du chantier UI mobile & tablette. Aucune infra de deep-link
n'existait (verifie en amont : pas de scheme personnalise, pas d'App
Links, pas d'assetlinks.json, pas de listener appUrlOpen).

- lib/mobile-app-redirect.ts : attemptOpenNativeApp() (pattern "smart
  app banner" classique - tente le scheme batchcooking://, bascule sur
  onFallback si la page est toujours visible passe un court delai) et
  isAndroidPhoneBrowser() (UA Android+Mobile, deliberement distinct de
  useIsMobileViewport() - une question de plateforme, pas de largeur).
  Respecte une preference "rester sur le web" (localStorage, cle
  exportee pour que l'onglet Parametres d'application - ticket #86 -
  la reutilise) sans jamais forcer la redirection.
- AndroidManifest.xml : nouvel intent-filter additif (scheme
  batchcooking, sans toucher au filtre MAIN/LAUNCHER existant).
- Nouvelle page /telecharger (publique, hors RequireAuth - un visiteur
  deja connecte sur le web mais sans l'appli sur cet appareil a une
  raison legitime d'y atterrir) : lien direct vers le proxy existant
  ${API_BASE_URL}/mobile/download (rien a changer cote API), bouton
  "reessayer d'ouvrir l'appli".

Deliberement pas de changement sur la landing page ici (lien discret
actuel laisse tel quel) - c'est le ticket #85, separe pour rester
petit/revuable.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (141/141 sur cette branche - #91 n'est pas encore
merge, layout.cy.ts y est donc encore dans son etat pre-#91) et
composants (69/69, incluant le nouveau mobile-app-redirect.cy.tsx).
Note technique : le premier essai de test du composant tentait un vrai
window.location.href vers le scheme personnalise depuis Cypress/
Electron, ce qui bloquait le runner indefiniment (protocole inconnu
mal gere par Electron) - corrige en rendant ce seul appel injectable
(openAppUrl, reserve aux tests) sans toucher au comportement reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:20:16 +02:00
0fd4928efc feat(layout): socle UI mobile - nav, feuille et dialogs plein ecran
All checks were successful
CI / lint (push) Successful in 51s
CI / build (push) Successful in 2m10s
CI / intent-service-test (push) Successful in 3m58s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 16m33s
CI / build-android (push) Has been skipped
Fondation du chantier UI mobile & tablette (#91) : jusqu'ici, toute
adaptation mobile de ce repo etait du CSS pur sur le meme DOM (aucun
composant ne branchait son JSX selon la largeur/plateforme). Ce ticket
introduit le premier vrai branchement, et les primitifs que les 8
tickets suivants du chantier reutiliseront.

- useIsMobileViewport() (apps/web/src/lib/) : point de verite unique,
  base sur matchMedia, live (reagit a un resize/une rotation).
- _breakpoints.scss : $phone/$tablet Sass + mixins phone/phone-up/
  tablet-down, remplacant les nombres magiques 640/1024 repetes en dur
  dans 6 fichiers (AppLayout, landing, recipes, recipe-picker-dialog,
  planning-page, dialog, tours-page). Les deux occurrences de 860px
  (landing hero, tours-page) sont deliberement laissees telles quelles
  - un collapse de grille 2 colonnes qui n'a rien a voir avec le
  breakpoint tablette du catalogue recettes, les unifier aurait change
  un comportement visuel reel sans raison.
- useNativeDialogBehavior : mecanique (showModal/close event/clic
  exterieur) extraite de Dialog.tsx, partagee avec le nouveau
  BottomSheet.tsx (feuille mobile plein-largeur, meme fondation
  <dialog> natif que Dialog, juste ancree en bas).
- AppLayout : sur telephone, la sidebar n'est plus reflow-ee en CSS en
  barre horizontale (l'ancien traitement) - elle est remplacee par une
  vraie barre d'onglets basse (Planning/Recettes/Liste de courses +
  Plus), le "Plus" ouvrant une BottomSheet combinant Parametres et
  Compte (deux dropdowns flottants separes cote desktop -> un seul
  point d'entree mobile, IA plus simple, un seul emplacement d'onglet).
- dialog.scss : vrai plein-ecran (bord a bord, sans arrondi) sous le
  breakpoint phone au lieu d'une marge simplement reduite.

Verifie en local : pnpm --filter web exec tsc -b --force, biome check,
pnpm --filter web build, suite Cypress e2e complete (140/140) et
composants (63/63) - y compris layout.cy.ts, reecrit pour tester la
nouvelle barre basse (l'ancien test de la barre horizontale n'a plus
de sens, ce traitement n'existe plus).

Closes #91

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:03:43 +02:00
8315f737d4 Merge pull request 'fix(auth): la déconnexion retombe sur / plutôt que /login' (#79) from fix/logout-redirect-landing into main
All checks were successful
CI / build (push) Successful in 3m4s
CI / lint (push) Successful in 3m8s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 10m12s
CI / test (push) Successful in 17m13s
CI / build-android (push) Successful in 5m6s
2026-09-07 00:25:04 +02:00
21cd8f014d fix(auth): la deconnexion retombe sur / plutot que /login
All checks were successful
CI / lint (push) Successful in 3m1s
CI / build (push) Successful in 4m32s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 11m28s
CI / test (push) Successful in 22m7s
CI / build-android (push) Has been skipped
RequireAuth.tsx affiche deja la LandingPage publique pour un visiteur
deconnecte exactement sur / (issue #41) — seul handleLogout naviguait
encore explicitement vers /login. Change juste la destination de
navigate() ; aucun autre changement necessaire.

Scenario Cypress mis a jour en consequence (le pas existant "the URL
should be the home page" suffit, pas besoin d'un nouveau pas).

Verifie en local, suite complete : pnpm lint, pnpm --filter web build,
pnpm run e2e (139/139, apres avoir nettoye un process vite zombie sur le
port 5173 laisse par une precedente invocation cypress dans cette meme
session — voir dev-environment-gotchas pour le detail).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:59:18 +02:00
3abb7217dd feat(mobile): proxie le telechargement de l'APK via l'API (pas de CORS)
All checks were successful
CI / lint (push) Successful in 2m59s
CI / build (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m46s
CI / e2e (push) Successful in 9m33s
CI / test (push) Successful in 17m20s
CI / build-android (push) Has been skipped
Suite au fix de /releases/latest (precedent), l'utilisateur voulait un
lien qui declenche le telechargement du .apk directement, sans quitter
l'appli web. Verifie en direct : fetch("https://git.dev.kyuno.fr/api/...")
depuis le navigateur echoue avec "Failed to fetch" (confirme via un essai
en mode no-cors : la requete reseau aboutit bien, seule la reponse est
bloquee) — Forgejo n'envoie aucun header CORS sur son API Releases,
publique ou non. Ca ne concerne pas que la page d'accueil : le hook de
verification de mise a jour (#66, useAppUpdateCheck) faisait exactement le
meme fetch cross-origin depuis la WebView native, et echouait donc tout
aussi silencieusement en production sans que personne ne puisse s'en
rendre compte (le hook avale toute erreur reseau par design).

Ajoute un petit module apps/api/src/modules/mobile/ (2 routes publiques,
aucune ne touche la base de donnees) :
- GET /mobile/latest-release -> { version } (proxy JSON, remplace le fetch
  direct de useAppUpdateCheck)
- GET /mobile/download -> redirection 302 vers l'asset .apk reel de la
  derniere release (ou vers la page /releases/latest en repli si
  Forgejo est injoignable ou qu'aucun .apk n'est encore attache)

Cote frontend : LandingPage.tsx et UpdateBanner.tsx pointent maintenant
vers ce proxy (`${API_BASE_URL}/mobile/download`, target="_blank") au lieu
d'une URL Forgejo directe — un clic, telechargement immediat, jamais de
navigation hors de l'appli. useAppUpdateCheck.ts appelle desormais
apiClient.getLatestMobileVersion() au lieu d'un fetch direct.

Verifie en local (sans Postgres, ces routes n'en ont pas besoin) contre
les vraies donnees Forgejo :
  GET /mobile/latest-release -> {"version":"v0.2.0"}
  GET /mobile/download -> 302, Location vers le vrai .apk signe

pnpm lint / tsc (api + web) / pnpm --filter web build : tous verts. Le
scenario Cypress "offers the Android app download" echoue encore en local
(meme souci pre-existant deja documente sur la PR precedente, confirme
independant du code par CI reel) ; mis a jour pour asserter le nouveau
href relatif.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:40:06 +02:00
29bd413de3 fix(mobile): corrige les liens vers /releases/latest/download
All checks were successful
CI / lint (push) Successful in 3m3s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 4m28s
CI / e2e (push) Successful in 9m31s
CI / test (push) Successful in 17m39s
CI / build-android (push) Has been skipped
Verifie en conditions reelles apres le premier vrai succes de bout en
bout de release.yml (v0.2.0, Release Forgejo #12 avec batchcooking.apk
attache) : Forgejo, contrairement a GitHub, n'implemente pas le
raccourci /releases/latest/download/<fichier>. Seul /releases/latest
lui-meme resout (redirige vers la page de la release) :

  /releases/latest/download/batchcooking.apk -> 404
  /releases/latest                            -> 303 vers /releases/tag/v0.2.0 (OK)
  /releases/download/v0.2.0/batchcooking.apk  -> 200 (OK, mais pas "latest")

Corrige les deux endroits qui presumaient le raccourci GitHub (issue #65
et #66, deja mergees) pour pointer vers /releases/latest tout court : un
clic de plus via la page de release Forgejo pour atteindre le fichier,
mais un lien qui resout toujours plutot qu'un raccourci qui 404 en
permanence. Met a jour le test Cypress de la page d'accueil et le
commentaire de release.yml en consequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:03:14 +02:00
503781c95f feat(landing): lien de téléchargement de l'app Android (#65)
Some checks failed
CI / lint (push) Successful in 5m4s
CI / build (push) Successful in 3m38s
CI / intent-service-test (push) Successful in 6m41s
CI / test (push) Failing after 11m29s
CI / e2e (push) Successful in 14m56s
Sur la page vitrine `/`, sous la rangée de CTA du hero (pas un 3e bouton :
un compte reste nécessaire quelle que soit la plateforme), un lien discret
vers l'APK Android + une ligne d'aide.

- Cible : l'asset `batchcooking.apk` de la dernière release taguée Forgejo
  (`/releases/latest/download/…`, nom de fichier stable) — pas un artefact
  de CI. 404 tant que #64 + la correction de release.yml ne sont pas faits :
  état transitoire assumé, ce ticket n'ajoute que le lien.
- `mobileHint` : rappel honnête que c'est un .apk à sideloader, pas une
  distribution Play Store.
- i18n `landing.hero.mobileDownload` / `mobileHint` ; style discret dans
  landing-page.scss ; scénario Cypress de plus (présence + href du lien).

Vérifs : `pnpm build` + `pnpm lint` OK. `landing.feature` non lancé en
local (le hoist `tslib` de `@cypress/vite-dev-server` dans ce node_modules
est cassé par un `pnpm install --force` interrompu + un verrou concurrent
sur `@esbuild/win32-x64` ; `pnpm-lock.yaml` inchangé, la CI n'est pas
affectée) → couvert par le job `e2e` de la CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:26:50 +02:00
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
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
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
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
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
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