Le job `sanity-build` (build de `apps/api/Dockerfile` sans push) devient
`build-and-push-images` : `docker login` sur le registre Forgejo intégré
via le token Actions du run (`permissions: packages: write`), puis build +
push des 3 images `batchcooking-{app,intent-service,llm-worker}` taguées
`vX.Y.Z` et `latest`. Un job séquentiel plutôt qu'une matrix — 3 builds
concurrents saturent le daemon Docker du runner unique (cf. mémoire
`ci-forgejo-runner-constraints`) sans gain réel pour une release.
`docker-compose.yml` référence maintenant ces images
(`image: git.dev.kyuno.fr/kyuno/batchcooking-*:${IMAGE_TAG:-latest}`) avec
le bloc `build:` gardé en repli local — Portainer peut tirer les images
pré-construites ou continuer à builder depuis Git. `.env.example` documente
`IMAGE_TAG` ; le README (section registre + Release) est réécrit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouvelle section `/admin/utilisateurs` (nav + route) : tableau des comptes
(date, nom, email, foyer) et un `Dialog` de création en deux temps —
formulaire prénom/nom/email, puis affichage unique du mot de passe
temporaire généré, avec bouton « Copier ». Validation cliente via
`adminCreateUserSchema`, erreurs API traduites (`EMAIL_ALREADY_IN_USE`
déjà mappé). Spec Cypress `admin-users.cy.ts` (API mockée).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`GET /admin/api/users` (liste, plus récent d'abord, plafonnée à 200) et
`POST /admin/api/users` (création à la main), tous deux derrière
`requireAdmin`. La création génère un mot de passe temporaire aléatoire
(`randomInt`, charset sans caractères ambigus, comme `generateInviteCode`)
renvoyé une seule fois ; le compte démarre sans foyer, comme une
inscription self-service. Évènement `admin.user_created` (fire-and-forget)
pour distinguer les comptes provisionnés à la main dans le dashboard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`adminCreateUserSchema` (prénom/nom/email, sans mot de passe — généré
côté serveur) + les vues `AdminUserListItemView` / `AdminUserCreatedView`
consommées par `POST`/`GET /admin/api/users`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L'intégration Forgejo (« Créer un ticket » depuis un message de contact,
issue #81) restait invisible en prod Docker : `docker-compose.yml` ne
listait pas `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO` dans le
bloc `environment:` du service `app`, et `**/.env` est `.dockerignore`d — le
process API ne voyait donc jamais ces variables, `ForgejoService.isConfigured()`
renvoyait `false` et le bouton restait masqué sur `/admin/messages`.
- `docker-compose.yml` : les 3 `FORGEJO_*` passées au conteneur via
`${VAR:-}`, même forme que `ADMIN_JWT_SECRET` / `INTERNAL_WORKER_SECRET`.
- `config/env.ts` : `blankAsUnset` sur les 3 champs — `${VAR:-}` arrive
comme `""` quand le `.env` du stack laisse la variable vide ; sans ça une
valeur vide ferait échouer `.url()` / la regex `owner/repo` et planter
`envSchema.parse()` au boot. Même parade que `COOKIE_SECURE`. `envSchema`
exporté pour le test.
- `.env.example` (racine) : documente les 3 variables au niveau compose.
- `apps/api/test/env.test.ts` : blanc = absent, + non-régression sur une
valeur non vide mais mal formée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`apps/web/package.json` 0.2.2 → 0.2.3 — alimente `__APP_VERSION__` (pied de
sidebar + comparaison de `useAppUpdateCheck`), même convention que
`chore(release): prépare la v0.2.2`.
Le tag `v0.2.3` déclenchera `release.yml` : APK signé (versionName 0.2.3,
versionCode 203) + Release Forgejo.
Contenu depuis v0.2.2 : tickets #128-#134 (issue #3 « Améliorations
diverses ») — assaisonnements « selon le goût », wizard d'inscription
réordonné, techniques hors-catalogue, régimes calculés depuis les
ingrédients, barre d'actions collante du formulaire recette, recherche
commune aux sources, afficher/masquer le mot de passe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le hash InRelease vs Packages.gz du dépôt apt Google Chrome (upstream)
correspond à nouveau (233e56de…) après ~21 min de désynchro CDN qui a fait
échouer runs 277-279 sur `apt-get update` (jobs test + e2e).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Run 278 : `test` et `e2e` ont échoué en ~1 min sur `apt-get update` (dépôt
apt Google Chrome incohérent en amont), avant toute exécution de code.
`lint` + `build` verts. Vérif locale complète verte.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouveau composant `components/ui/PasswordInput` : un `<input>` de mot de
passe avec un bouton œil (`lucide-react` Eye/EyeOff) qui bascule le `type`
entre "password" et "text". Visibilité en état local, toujours masquée au
montage, jamais persistée. Bouton `type="button"` (ne soumet pas le
formulaire) + `aria-label` traduit (`common.passwordInput.*`) +
`aria-pressed`.
Branché dans `LoginPage` et `SignupPage` à la place du `<input
type="password">` nu. La règle `.auth-card button` de `auth-form.scss` est
resserrée sur `button[type="submit"]` pour que le bouton œil n'hérite pas
du traitement plein-largeur du bouton de connexion.
Tests : `cypress/component/PasswordInput.cy.tsx` (7 cas, composant isolé) +
`cypress/e2e/auth-form.cy.ts` (mode layout, l'œil sur /login et /signup).
Closes#134
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Avant : chaque onglet de source rendait son propre champ recherche, avec
son propre state, et `RecipeSourcesPanel` est remonté (`key={sourceKey}`)
à chaque changement de source — la requête tapée était donc perdue en
passant d'une source à une autre.
Maintenant : un seul champ, dans l'en-tête de `RecipesPage`. `search` /
`debouncedSearch` (déjà présents pour les onglets réels) sont rendus en
permanence et `debouncedSearch` est passé en prop à `RecipeSourcesPanel`,
qui n'a plus ni `useState` de recherche, ni `<input>`, ni effet de
debounce internes — son effet de `browse` dépend de la prop. La requête
survit donc au changement de source (le `key` remonte toujours la liste +
le préchargement, mais le terme vit au-dessus), et elle est commune aux
onglets réels comme aux sources.
`RecipePickerDialog` : même chose — le champ recherche sort du bloc de
filtres `activeSourceKey === null` (les filtres ingrédients/régimes/foyer
y restent, ils ne concernent que les onglets en base) et `debouncedSearch`
est passé au panneau.
- SCSS : `.recipes-page__header--sources` supprimé (le panneau n'a plus
d'en-tête). Clé i18n morte `recipes.sources.searchPlaceholder` retirée.
- `specs/frontend-architecture.md` : recherche au niveau page.
Tests : `recipe-sources.feature` — nouveau scénario « la requête est
conservée en changeant d'onglet de source » (tape « poulet » sur TheMealDB,
bascule sur Marmiton, le champ garde « poulet » et Marmiton est interrogé
avec `query=poulet`). `pnpm lint` / `pnpm build` verts ; Cypress local :
recipe-sources 8/8, recipes 5/5, recipes.cy 12/12, planning 3/3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouvel ordre : régime → allergènes (les deux « préférences ») → foyer →
sources. L'étape sources devient la dernière et n'est atteinte que si
l'étape foyer vient de **créer** un foyer — rejoindre ou passer termine le
wizard vers `/` (un foyer rejoint a déjà ses sources ; un foyer passé n'a
rien à quoi les rattacher).
- `onboarding-steps.ts` (nouveau) : séquence des étapes + `onboardingStepIndicator()`
en un seul endroit. Les compteurs « Étape N sur M » étaient codés en dur
page par page, ce qu'un réordonnancement cassait. régime/allergènes/foyer
sont en amont du choix « créer vs rejoindre/passer » donc affichent « sur 3 » ;
seule l'étape sources, jamais atteinte hors du chemin à 4 étapes, affiche « 4 sur 4 ».
- `OnboardingDietPage` → enchaîne sur `/onboarding/allergenes`.
- `OnboardingAllergensPage` → enchaîne sur `/onboarding/foyer` ; bouton « Continuer »
(plus « Terminer ») ; suppression du `getCurrentHouse()` + `totalSteps` dynamique,
devenus inutiles maintenant que cette étape précède le foyer.
- `OnboardingHouseholdPage` → `goToNextStep(createdHousehold)` : `/onboarding/sources`
seulement si créé, sinon `/`. `JoinHouseholdForm`/`SkipButton`/branche défensive
« déjà un foyer » terminent vers `/`.
- `OnboardingSourcesPage` → dernière étape : submit, court-circuit « aucune source »
et catch vont tous vers `/` ; bouton « Terminer ».
- `App.tsx` : blocs `<Route>` réordonnés + commentaire de doc du groupe.
- `specs/frontend-architecture.md` : ordre, diagramme mermaid, condition sources.
Tests : `onboarding.feature` réécrit — 4 parcours (walk-through complet avec
foyer créé + sources ; préférences + foyer passés ; foyer créé sans source
implémentée → étape sources auto-sautée ; foyer rejoint → pas de sources),
compteurs vérifiés sur chaque chemin. `pnpm lint` / `pnpm build` verts ;
Cypress local : onboarding 4/4, régression auth/preferences/household-settings 16/16.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
`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>
`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>
`.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>
- `apps/web/package.json` 0.2.1 → 0.2.2 (alimente `__APP_VERSION__` : pied
de sidebar + comparaison de `useAppUpdateCheck`), même convention que le
commit `chore(web): version 0.2.1`.
- `release.yml` : la step « Crée la Release Forgejo » devient idempotente —
si le tag est déjà connu de Forgejo (tag poussé à la main, ou ré-exécution
du job), l'API répond 409 ; on récupère alors la release existante au lieu
de faire échouer le job. C'est ce 409 qui avait fait tomber le run du tag
v0.2.2 poussé en avance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le parcours « tout voir » (filtre texte vide) de la source Manger Bouger
s'arrêtait après la 1re page (9 recettes) sur le serveur déployé.
Cause : `list()` ajoutait toujours un `&query=` final, même vide. Sur le
serveur déployé, `…/recettes?diet=ALL&page=N&query=` renvoyait la page 1
pour tout `N`, et le garde-fou anti-boucle de #38 (« 0 nouvel externalId
⇒ fin de liste ») coupait le défilement. La requête catalogue nue
`?diet=ALL&page=N` pagine correctement.
- `manger-bouger.ts` `list()` : n'ajoute `&query=` que si le terme de
recherche est non vide (après `trim`), comme les adaptateurs voisins
(marmiton / quitoque / ohmonbento). Le chemin filtré ne change pas.
- Commentaires `LIST_URL` / `hasMorePages` corrigés : la pagination
« vérifiée en direct » ne valait que pour le chemin avec `query` texte.
- Tests `manger-bouger.test.ts` : `list({})` construit une URL sans
paramètre `query` ; un `query` blanc = pas de filtre ; le catalogue nu
pagine quand même (`nextCursor`) ; `list({ query: salade })` ajoute
toujours `&query=salade` (non-régression).
Closes#78.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le job `test` a échoué sur `a252640` par contention Postgres avec la CI de
#124 lancée en parallèle (15 timeouts de hook « before each » + quelques
PrismaClientKnownRequestError, tous hors périmètre — les 7 tests `Contact`
passent). #124 (surensemble de cette branche) est vert. Relance isolée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`:app:mergeReleaseResources` (job CI `build-android`, main uniquement)
échoue : AAPT interdit la chaîne « -- » à l'intérieur d'un commentaire XML,
et `res/values-night/colors.xml` (issue #104, PR #120) citait les tokens
`_theme.scss` avec leur préfixe de custom-property CSS (`--color-*`) dans
ses commentaires.
Commentaires réécrits sans aucun « -- » (tokens nommés « color-x »), même
convention que `res/values/colors.xml`. Valeurs et éléments `<color>`
inchangés. Non vérifiable en local (pas de JDK 21 sur cette machine) ; le
job `build-android` du prochain run sur `main` valide.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
`AppTheme.NoActionBar` a bien un parent `DayNight`, mais sans dossier
`res/values-night/` ses `@color/colorPrimaryDark` / `@color/colorBackground`
résolvaient aux valeurs claires même en mode sombre système : la bande de
la barre d'état et la fenêtre pré-WebView (flash de démarrage à froid +
fond du splash) restaient sur la palette « porcelaine » claire pendant que
le téléphone — et le contenu WebView de l'app — passaient en sombre.
- `android/app/src/main/res/values-night/colors.xml` (nouveau) : variantes
sombres de `colorPrimary` / `colorPrimaryDark` / `colorAccent` /
`colorBackground`, calées sur les tokens `--color-*` sombres de
`_theme.scss`. En sombre, la bande de la barre d'état devient le
`--color-background` sombre (`#14181A`), sans raccord avec l'app.
`windowLightStatusBar=false` (icônes claires) reste correct dans les deux
modes. Aucun changement de `styles.xml` : il référence déjà ces noms de
couleur, le parent `DayNight` + `values-night/` fait le reste.
- `index.html` : `<meta name=theme-color>` (light/dark) pour teinter
aussi le chrome du navigateur mobile.
Vérification : impossible sur appareil/émulateur dans ce contexte — le job
CI `build-android` (main uniquement) compilera au moins la ressource. La
synchro live de la barre d'état avec le sélecteur de thème *in-app* (via
`@capacitor/status-bar` + JS) reste un éventuel suivi ; ce correctif règle
le « ne suit pas les réglages système » du rapport.
Closes#104
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
`isMobile = isAndroidPhoneBrowser()` est une détection d'user-agent : la
WebView Capacitor contient « Android »/« Mobile », donc le bouton
« Ouvrir dans l'application » s'affichait aussi *dans* l'app packagée — où
le taper tente de relancer l'app via le scheme, échoue, et retombe sur
`/telecharger`.
- Nouveau `canOfferApp = isMobile && !Capacitor.isNativePlatform()` — « un
navigateur *téléphone* où pousser l'app native a du sens ». Gate le
bouton et la rétrogradation de « Créer un compte » en secondaire.
- Le lien `/telecharger` (variante desktop) gagne aussi `&&
!Capacitor.isNativePlatform()` : sur tablette la WebView Capacitor peut
ne pas avoir « Mobile » dans son UA, donc `!isMobile` ne suffisait pas.
Aucun changement sur le web (`Capacitor.isNativePlatform()` y est toujours
`false`, donc `canOfferApp === isMobile`) — `landing.feature`
(navigateur téléphone + desktop) revérifiée, 7/7. Le chemin natif n'a pas
de couture de test depuis un run web (limitation déjà documentée, cf.
`app-settings-page.cy.ts`).
Closes#103
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
`useAppUpdateCheck` tournait au montage de `<UpdateBanner />`, mais celui-ci
n'était monté que dans `AppLayout`, derrière `RequireAuth` : un utilisateur
non connecté sur l'app native voit `LandingPage`, `AppLayout` n'est jamais
monté, donc aucune vérification tant qu'on n'est pas connecté — alors que
le back suppose l'inverse (`mobile.routes.ts` : « runs on every app start,
session or not »).
- `<UpdateBanner />` monté dans `App.tsx`, hors `<Routes>`, donc rendu pour
toute route quel que soit l'état d'auth. Garde `!isNativePlatform()` et
throttle 6 h inchangés ; rend toujours `null` sur le web.
- `update-banner.scss` : passe en `position: fixed` (bandeau haut,
`env(safe-area-inset-top)`, `z-index: 100`) pour ne pas entrer dans la
mise en page / le calcul de hauteur d'une page routée (`.app-layout` est
`height: 100vh; overflow: hidden`).
Pas de test e2e : `Capacitor.isNativePlatform()` n'a pas de couture pour
simuler le natif depuis un run web (posture déjà assumée pour
`useAppUpdateCheck` / `UpdateBanner`, voir `app-settings-page.cy.ts`). Suite
e2e existante (layout / smoke / app-settings) revérifiée : aucun régression.
Closes#106
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`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>
`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>
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>
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>
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>
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>
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>
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>
Le wait-step a fonctionne (~21 min d'attente puis "proceeding"), mais un
petit residu de contention subsiste : 460 passing / 8 failing, meme
signature connue (timeout 10s + unique constraint sur key). Nette
amelioration par rapport a avant le correctif (auparavant ~280-410
passing / 14-25 failing) - la file se vide au fur et a mesure que les
autres PR terminent (#93 et #96 deja mergees).
Le nouveau step "Wait for any earlier test job" a fonctionne comme
prevu cette fois : il a attendu ~25 min un job anterieur toujours en
cours (task 1259) puis a abandonne proprement (exit 1, "a re-run
should get through") plutot que de risquer une collision - aucun
signe de corruption/collision Postgres dans le log. Simple embouteillage
du a plusieurs PR en test simultanement ; devrait passer maintenant que
la file s'est videe (#93 mergee entre temps).
Le nouveau step "Wait for any earlier test job" a fonctionne comme
prevu (13 tentatives, ~4m30s a attendre le job 1217, puis
"proceeding") - mais Postgres a ete coupe quasi immediatement apres
(0 passing, Can't reach database server). Cause : d'autres jobs test
en cours sur d'autres PR (#93/#94/#95, sans le correctif) executent
encore `docker rm -f postgres` sans condition au demarrage de LEUR
propre run, y compris pendant que ce job-ci utilise deja le
conteneur. Le correctif protege un job qui demarre poliment, pas
contre un job non protege qui demarre en parallele. Je laisse #93/#94/
#95 tranquilles maintenant et je ne relance que celle-ci jusqu'a ce
qu'elle passe.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
Deux jobs `test` qui se chevauchent partagent un seul conteneur
`--name postgres` sur le reseau docker du runner (pas de bloc
`services:`) : leurs `prisma migrate deploy` + `seedReferenceData`
(beforeAll racine Mocha) rentrent en collision - "Unique constraint
failed on (key)", "No pending migrations" alors qu'il en reste,
decalages d'id tech_step/diet cassant des assertions sans rapport ;
et meme en dehors de ca ils s'affament mutuellement en RAM/CPU
(chacun entraine aussi un modele spaCy) jusqu'a ce que
resetDatabase() depasse le timeout de hook Mocha (10s) et que ca
cascade. Touche a repetition ce mois-ci (PR #47, #53, #54, et de
nouveau aujourd'hui sur #93/#94/#95), a chaque fois resolu par une
relance a vide une fois le runner plus calme - jamais un vrai bug de
la PR concernee.
`concurrency:` (le vrai correctif GitHub) est silencieusement ignore
par ce Forgejo (9.0.3, arrive en v14/Gitea 1.26). Un objet de verrou
(reseau, conteneur, fichier) fuirait : ce runner (forgejo-runner
v3.5.0) n'execute pas les steps `if: always()` suivant un step en
echec, donc pas de liberation fiable en fin de job. A la place :
s'appuyer sur ce que le runner nettoie toujours lui-meme - le
conteneur du job (supprime en succes, echec et annulation) - et
attendre tant qu'un job `test` de task id inferieur tourne encore.
Le plus petit id part en premier, donc deux jobs demarres ensemble ne
peuvent pas se bloquer mutuellement ; rien a liberer explicitement.
Trois ajouts, purement additifs (aucune ligne existante modifiee) :
- nouveau step "Wait for any earlier test job" (poll docker ps,
plafond ~25min) juste avant le demarrage de Postgres ;
- `timeout-minutes: 45` sur le job (couvre le temps d'attente
eventuel + garde-fou si le job reste bloque, vu l'absence de
--exit sur Mocha) ;
- le nettoyage `docker rm -f postgres` existant balaie aussi les
conteneurs `postgres-<suffixe>` orphelins (residus d'un essai
anterieur de nommage par run, deja abandonne sur cette meme
branche, jamais nettoyes faute de teardown fiable).
Travail deja fait et documente par une session precedente (branche
claude/magical-stonebraker-5dc636, verifie fonctionnel en run 130 :
"12x waiting for earlier test job(s)... puis proceeding") mais jamais
fusionne - retrouve et applique aujourd'hui en diagnostiquant la
meme contention sur trois PR en cours (#93/#94/#95).
Verification : impossible de re-simuler la course en local (pas de
second runner ici), mais le diff est purement additif et applique
sans le moindre conflit sur l'etat actuel de main - les trois commits
de ci.yml survenus depuis (upload-artifact v3, bit gradlew,
build-android) touchent un job entierement different du fichier.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cette fois : PrismaClientKnownRequestError "Foreign key constraint
violated: tour_scenario_step_target_key_fkey" dans le beforeAll
global (seedTourScenarios). Nouvelle variante de la meme famille
connue (plusieurs process de test partageant une seule base Postgres
CI, seed concurrent) plutot qu'un nouveau bug reel - ce ticket ne
touche ni reference-seed-data.ts ni quoi que ce soit lie aux tours.
Log complet aussi montre un timeout Docker au nettoyage du conteneur
("context deadline exceeded"), signe supplementaire de charge severe
sur le runner en ce moment.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
e2e a echoue sur des timeouts de 4s (#firstName, #houseName,
.leftovers-basket__toggle introuvables) - reproduit et verifie en local
sur cette meme branche : planning-leftovers.feature passe proprement
en 4s (2/2), aucun probleme reel. Le run CI montrait des blocs
"passing" anormalement lents partout (jusqu'a 22s pour quelques
assertions simples), signe de contention severe sur le runner pendant
que plusieurs PR tournent en parallele, pas une regression de ce
ticket.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
intent-service-test a echoue cette fois sur actions/checkout lui-meme
("Non-terminating error while running 'git clone': some refs were not
updated" puis "Cannot find module '.../actions-checkout@v4/dist/index.js'")
- meme classe de collision de cache d'actions que les echecs precedents,
juste une action differente touchee cette fois (voir
ci-forgejo-runner-constraints). Ce ticket ne touche aucun fichier lie a
ces actions.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
Can't reach database server at postgres:5432 / Server has closed the
connection - meme signature connue de contention runner que les
relances precedentes (voir ci-forgejo-runner-constraints), pas une
regression.
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>
24 echecs melant timeouts 10s, "Unique constraint failed on the
fields: (key)" (race d'upsert concurrent au seed) et
PrismaClientInitializationError "Can't reach database server" -
combinaison connue de signatures de contention sur le Postgres partage
du runner (voir ci-forgejo-runner-constraints), pas une regression.
Ce ticket ne touche que des fichiers Cypress/apps-web, aucun code
touchant la base.
Non-terminating error while running 'git clone' puis Cannot find
module '.../pnpm-action-setup@v4/dist/index.js' - signature connue de
collision entre deux jobs act demarrant en meme temps sur le cache
d'actions partage du runner (voir ci-forgejo-runner-constraints), pas
une regression.
PrismaClientInitializationError "Can't reach database server at
postgres:5432" pendant test, alors que ce ticket ne touche aucun
fichier apps/api - signature connue de contention sur le runner
partage (voir ci-forgejo-runner-constraints), pas une regression.
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>
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>
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>
L'app Android n'arrivait a se logger/inscrire nulle part (204 +
"erreur inattendue" cote client) : CORS_ORIGIN n'etait jamais defini en
prod (docker-compose.yml ne le passe pas a `app`), donc il retombait
sur son default `http://localhost:5173` (le serveur Vite de dev), qui
ne matche evidemment jamais l'origine reelle de l'appli packagee
(`https://localhost`, cf. capacitor.config.ts). Le site web n'est lui
jamais touche : il est servi depuis la meme origine que l'API en prod,
donc jamais de preflight CORS du tout sur /auth/*.
Le symptome trompeur vient du package `cors` : un preflight OPTIONS
recoit toujours 204, meme pour une origine rejetee — la difference
invisible sans inspecter les headers est l'absence de
Access-Control-Allow-Origin, qui fait ensuite bloquer le vrai POST
cote client sans jamais atteindre le serveur. Vu depuis les logs
serveur, la route a bien l'air "appelee" (le preflight matche le meme
chemin) alors que /auth/login lui-meme n'est jamais reellement recu.
CORS_ORIGIN devient une liste separee par des virgules (le type
`ExpressServerCoreOptions.corsOrigin` acceptait deja `string | string[]`)
et son default inclut desormais `https://localhost` — l'origine de
l'appli packagee est fixe et non secrete (contrairement au domaine du
site en prod, qui n'a de toute facon pas besoin d'etre dans cette
liste puisqu'il est same-origin), donc la coder en dur dans le default
couvre tout deploiement sans configuration supplementaire.
Verifie en local : script ad hoc qui demarre le vrai `createServer()`
et envoie un preflight OPTIONS /auth/login avec differentes origines —
`https://localhost` et `http://localhost:5173` recoivent maintenant
Access-Control-Allow-Origin, une origine arbitraire non listee
continue de ne pas le recevoir (pas de bypass en wildcard). Egalement
`pnpm --filter api exec tsc --noEmit` et `pnpm exec biome check` sur
les fichiers touches.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
3e vrai declenchement de release.yml (tag v0.2.0) : forgejo-release
echoue en 25s avec 'curl: command not found' — meme classe de probleme
que le docker manquant deja corrige pour sanity-build (#73) : ce runner
ne fournit pas ces outils par defaut, ci.yml's test job avait deja du
installer curl explicitement pour la meme raison.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2e vrai declenchement de release.yml (tag v0.2.0, apres le fix du docker
manquant sur sanity-build) : forgejo-release echoue en 35s. Log complet :
Creating new GitHub release for tag v0.2.0...
GitHub release failed with status: 405
(x3, puis "Too many retries. Aborting...")
Pas un souci de ref bare comme les autres actions de ce fichier (deja
corrige) : la requete de creation de release elle-meme repond 405 cote
Forgejo, une vraie incompatibilite d'API entre les deux plateformes sans
contournement connu cote configuration de softprops/action-gh-release.
Remplace ce step par deux appels directs a l'API REST native de Forgejo
(POST /repos/.../releases puis POST .../releases/{id}/assets) avec le
GITHUB_TOKEN deja fourni par le runner (permissions: contents: write
deja declare en tete de fichier) — evite toute dependance a la
compatibilite GitHub-Forgejo d'une action tierce. Pas d'equivalent a
generate_release_notes ici (fonctionnalite propre a GitHub, non repliquee
par Forgejo) : le corps de la release reste minimal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Premier vrai declenchement de release.yml (tag v0.2.0) : sanity-build
echoue en 17s avec 'docker: command not found' — ce runner Forgejo
self-hoste ne fournit pas docker par defaut dans les conteneurs de job,
meme constat deja rencontre par le job test de ci.yml qui l'installe
explicitement avant ses propres appels docker. sanity-build ne l'avait
jamais fait, presque certainement jamais reellement execute sur ce runner
depuis la migration vers Forgejo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml datait d'avant la migration vers Forgejo et n'avait jamais ete
reteste : refs d'action bares (actions/checkout@v4, softprops/action-gh-
release@v2) sans le prefixe URL complet qu'exige ce runner self-hosted
(meme contrainte deja rencontree et documentee dans ci.yml).
Ajoute aussi ce que #65 attendait reellement : un job build-android-apk
(meme recette que le job build-android de ci.yml, issue #64) qui construit
et signe l'APK, deduit versionName/versionCode du tag pousse (vX.Y.Z ->
versionName X.Y.Z, versionCode majeur*10000+mineur*100+correctif) et
l'attache a la Release Forgejo sous un nom de fichier stable
(batchcooking.apk) via softprops/action-gh-release. C'est ce nom stable qui
permet a /releases/latest/download/batchcooking.apk (page d'accueil, #65)
de toujours pointer vers la derniere release sans jamais changer de lien.
build.gradle : versionCode/versionName deviennent surchargeables via
-PandroidVersionCode/-PandroidVersionName (defaut inchange : 1 / "1.0"
pour tout build qui ne passe pas ces proprietes, comme le job
build-android existant de ci.yml sur un simple push main).
Duplique le job de build Android plutot que de factoriser en workflow_call
reutilisable — le support des workflows reutilisables sur ce runner
Forgejo/act self-hoste n'est pas verifie, et cette recette de ~15 lignes
est deja eprouvee telle quelle dans ci.yml ; pas la peine de risquer une
nouvelle surprise de compatibilite pour economiser un peu de duplication.
Non teste de bout en bout dans cette PR : release.yml ne se declenche que
sur un vrai tag pousse (jamais sur une simple branche/PR), et pousser un
tag ici declencherait potentiellement un vrai redeploiement Portainer en
production si PORTAINER_WEBHOOK_URL est deja configure — a tester
deliberement avec l'utilisateur, pas par un tag de test improvise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le tout premier build-android complet et signé (keystore enfin correct)
échouait quand même à la toute dernière étape :
GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
et download-artifact@v4+ ne sont pas actuellement supportés sur GHES.
v4+ exige l'API "Results" propre à github.com — non implémentée par ce
runner Forgejo self-hosted. v3 utilise encore l'ancienne API Artifacts,
seule compatible ici ; l'interface name/path utilisée par ce job est
identique entre les deux versions, aucun autre changement nécessaire.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Commit vide (aucun fichier modifie) — declenche le pipeline push sur main
pour verifier le nouveau secret ANDROID_KEYSTORE_BASE64 fourni par
l'utilisateur, ci.yml n'ayant pas de declenchement manuel (workflow_dispatch).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le tout premier run réel du job build-android (après le merge de #64)
échouait avec './gradlew: Permission denied' — gradlew était suivi par
git en 100644 (non exécutable) au lieu de 100755, probablement perdu au
commit initial du projet Android natif (#63) sur cette machine Windows.
Corrige le mode suivi par git (git update-index --chmod=+x) et ajoute un
chmod +x défensif juste avant l'exécution dans ci.yml, puisque ce bit
n'a aucun sens sous Windows et peut être reperdu silencieusement par un
futur commit sur cette machine.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le job `test` de la run précédente a échoué sur une saturation du runner
(P1002 Prisma + timeout Docker), pas sur le code — le même job échouait
au même moment sur toutes les branches, `main` compris. Diff 100 % frontend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Réutilise l'API Releases de Forgejo (publique, sans authentification) comme
source de vérité pour "quelle est la dernière version" — zéro nouvel
endpoint backend : useAppUpdateCheck compare App.getInfo().version
(@capacitor/app, nouvelle dépendance) au tag_name de
GET .../releases/latest, et pointe "Mettre à jour" vers l'URL stable de
téléchargement (issue #65).
Comparaison de version en logique pure (version-compare.ts), séparée du
hook effectful — testée isolément via un test de composant Cypress
(version-compare.cy.tsx, même patron que highlight-tech-steps.cy.tsx :
aucun montage, juste describe/it/expect sur des fonctions pures).
Natif uniquement (Capacitor.isNativePlatform()) : @capacitor/app et
@capacitor/preferences restent en import dynamique dans le hook, jamais
dans le bundle web (même patron que capacitor-token-store.ts) — vérifié
dans le build (deux petits chunks séparés, pas dans le bundle principal).
Vérification au démarrage à froid + à la reprise depuis l'arrière-plan
(appStateChange), avec un throttle de 6h et une mémorisation de la version
déjà rejetée dans @capacitor/preferences pour ne jamais re-harceler sur la
même version. Bannière discrète (pas une Dialog modale), bouton "Mettre à
jour" ouvrant le lien de téléchargement dans le navigateur système plutôt
qu'un flux PackageInstaller in-app.
Vérifié : pnpm lint, pnpm --filter web build, le test de composant (9/9),
et layout/smoke/planning-page e2e (18/18, la bannière étant un no-op sur
le web ne devait rien casser — confirmé).
Closes#66
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 échecs éparpillés (timeouts 10s sur des suites sans rapport entre elles,
PrismaClientKnownRequestError, un mismatch d'ID façon ré-seed concurrent) sur
373 passing — signature exacte de la course documentée dans
ci-forgejo-runner-constraints (postgres partagé + contention RAM/CPU du
runner), pas un vrai échec de code : ce PR ne touche que ci.yml/le projet
Android/le README, rien que la suite Mocha exerce différemment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouveau job build-android dans ci.yml, gardé par needs: sur les 5 jobs
existants (lint/test/intent-service-test/build/e2e) et limité à main via
if: github.ref == 'refs/heads/main' — c'est ce qui garantit que l'APK n'est
empaqueté qu'une fois la suite de tests existante verte, pas juste construit
en parallèle d'elle.
APK signé avec un vrai keystore de release (jamais généré par la CI —
Android refuse une mise à jour signée par une clé différente de celle déjà
installée) : ajoute signingConfigs.release à build.gradle, lu depuis
l'environnement (ANDROID_KEYSTORE_PATH/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD),
jamais codé en dur. *.jks/*.keystore désormais réellement ignorés par git
(étaient commentés dans le .gitignore généré par Capacitor).
README : documente les 5 secrets Forgejo requis et la génération du
keystore. Aucune suite de tests instrumentés Android n'existe (zéro code
natif au-delà de la coquille Capacitor) — "testée" est satisfait par le
gate sur la suite existante, pas par une suite native à inventer.
Risque documenté, pas résolu par avance : aucun build Android n'a jamais
tourné sur ce runner Forgejo auto-hébergé, contrairement aux 4 autres jobs.
Closes#64
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Dernière étape du portage mobile #39 : génère et versionne le projet natif
Android (`npx cap add android`) que les phases 1 à 3 (jeton Bearer, passe
responsive, plomberie web) préparaient déjà.
- `apps/web/android/` — projet natif Capacitor 8.5. Permission `INTERNET`
présente par défaut, `applicationId` = `fr.kyuno.batchcooking`.
- Thème natif aligné sur la palette « Mise en Place » (`_theme.scss`) :
`colors.xml` redéfinit `colorPrimary`/`Dark`/`Accent` (basilic/vermillon)
par-dessus les défauts Material de la lib capacitor-android ; barre de
statut basilic foncé, fond de fenêtre « porcelaine » pour ne pas flasher
blanc au démarrage à froid ; `postSplashScreenTheme` pour la transition.
- Icône adaptative + splash (clair et sombre, toutes densités) générés
depuis `apps/web/assets/logo.svg` (monogramme « bC », géométrie seule, pas
de `<text>`) via `@capacitor/assets` ; sources et commande de régénération
documentées dans `apps/web/assets/README.md`. `@capacitor/assets` n'est pas
ajouté aux dépendances (il tire `sharp`, hors `onlyBuiltDependencies`) —
lancé via `npx`.
- `.gitignore` racine : ignore `/.idea/` (Android Studio ouvre désormais le
dépôt comme projet). `biome.json` : exclut `apps/web/android` du lint.
- README : section « Application mobile (Android) » (prérequis + flux de
build `build:capacitor` → `cap sync` → Android Studio).
Le bundle web copié sous `android/app/src/main/assets/public/` reste
git-ignoré (régénéré par `pnpm build:capacitor && npx cap sync android`).
Build de l'APK + test sur appareil : à finir dans Android Studio — la CI
self-hosted ne build pas Android.
Vérifs : `pnpm build` + `pnpm lint` OK ; `npx cap sync android` OK ; tous
les XML natifs bien formés.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
Le step Gherkin partagé « the recipe should include the ingredient »
utilisait un `.should("be.visible")` nu sur `.ingredient-row__name`.
Sur le formulaire d'édition de recette, la liste d'ingrédients peut se
trouver sous le pli du scroll propre à `.app-content` (Nom/Description/
Photo/Portions/Visibilité/Régimes viennent avant) — un `.should`
seul ne scrolle pas automatiquement, contrairement aux commandes
d'interaction (`.click()`/`.check()`).
Repéré comme bug pré-existant et hors périmètre lors de la vérification
de #29/#30 (voir la note dans tech-step-metadata-editor-redesign.md).
Même correctif que « I should see the highlighted technique »
(common.steps.ts) et « I should see the highlighted temperature »
(recipes.ts) : `.scrollIntoView()` avant l'assertion.
Reproduit avant correctif (recipe-form.feature, scénario « Preloads an
existing recipe's ingredients when editing »: 4/5 puis 5/5 après le
correctif). Suite e2e complète: 27/28 fichiers verts (la seule
exception, admin-monitoring.cy.ts, est un flake distinct et
sans rapport, signalé séparément).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.recipe-detail-panel__steps utilisait un compteur CSS (counter-reset/
counter-increment: step) sur un sélecteur `li` — un descendant, pas
seulement les enfants directs. Depuis la refonte du popover de correction
(tech-step-metadata-editor-redesign), celui-ci imbrique ses propres <li>
plusieurs niveaux plus bas (une par ingrédient/ustensile/température
attaché, plus la liste du picker de technique — jusqu'à ~74 entrées) :
chacun incrémentait ce même compteur "step", faussant le numéro affiché de
toutes les étapes suivantes dès qu'une correction avec métadonnées
attachées (ou son picker de technique) était ouverte. C'est le bug
rapporté : "les étapes affichées sont numérotées n'importe comment".
Correctif : `li`/`img`/`p` → `> li`/`> img`/`> p` (enfants directs
uniquement) dans cette règle. Même principe pour `img`/`p` : leurs styles
(image bloc, paragraphe inline sans marge) ne doivent s'appliquer qu'au
propre <p>/<img> de l'étape, pas à ceux du popover imbriqué plus loin.
Test de non-régression ajouté (recipes.cy.ts) : ouvre une correction avec
ingrédient + ustensile attachés puis le picker de technique, vérifie via
getComputedStyle que ces <li> imbriqués n'ont pas rejoint la chaîne du
compteur (`counter-increment: none`) alors que les <li> des étapes
elles-mêmes l'ont toujours (`counter-increment: step 1`) — confirmé que ce
test échoue bien sans le correctif (`step 1` au lieu de `none`) avant de le
restaurer.
Vérifié : pnpm lint + build clean. recipes.cy.ts (9/9, dont le nouveau
test), recipes.feature (5/5), recipe-sources.feature (6/6).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute
l'édition de la température, jusqu'ici la seule des 4 métadonnées sans
aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée
sous celle-ci).
- StepDescription.tsx : le surlignage de température devient cliquable
(un <button>, plus un <span> mort) — rouvre le popover de correction de
la technique à laquelle il est rattaché (findTechStepForTemperature,
résolu par span puisque la température n'a pas d'existence propre, elle
est une métadonnée de clause comme les ingrédients/ustensiles).
- TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique,
Ingrédients, Ustensiles, Température) restent toutes visibles en même
temps — plus de bascule de tout le popover vers un sous-écran pendant
l'ajout d'un ingrédient/ustensile/température, seule la section
concernée affiche son propre mini-formulaire, les autres (dont le
picker de technique) restent inchangées à côté. Le picker de technique
se cache derrière "Changer" une fois une technique choisie (rien à
décider une fois confirmée correcte). Chaque ingrédient/ustensile/
température attaché s'affiche en ligne avec sa citation exacte du texte
source (nouveau : la traçabilité vers le texte existait déjà en donnée,
mais n'était affichée nulle part). Nouveau sélecteur à 3 modes
(°C / Thermostat / Qualitatif) pour la température, cohérent avec le
modèle de données (un seul des trois à la fois). Icônes SVG par
catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based
que layouts/nav-icons.tsx.
- recipes.scss : nouveau langage visuel du popover (sections avec icône,
lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter"
en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux
surlignages (.step-tech-step, .step-temperature) passent de
`cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais
déclencheurs cliquables.
- i18n : nouvelles clés (changeTechnique, temperatureSection,
addTemperature, removeTemperature, temperatureMode.*) ; clé
currentTechnique supprimée (le pictogramme remplace la phrase "Technique
retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône
+ le porte déjà visuellement).
- Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests,
dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau
scénario Gherkin "cliquer un surlignage de température rouvre le popover
de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au
scénario de relabel existant.
Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress
exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx
10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts
8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec
pré-existant et sans rapport (recipe-form.feature, "Preloads an existing
recipe's ingredients when editing") reproduit identiquement avec ce
travail entièrement stashé — confirmé non lié.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/
corrections) acceptait ingredients/utensils pour compléter les métadonnées
d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature
n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update
complet de recette. La température ajoutée en #22 n'avait jamais eu son
propre circuit de correction manuelle.
- packages/shared/src/schemas/recipe.ts : nouveau
manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative
mutuellement exclusifs — exactement un des trois — même span start/end
que ingredients/utensils). Le champ temperatures rejoint ingredients/
utensils dans le même lot combiné (les trois voyagent ensemble : dès que
l'un est présent, les trois sont résolus, un absent devient [] donc
effacé — jamais une fusion partielle catégorie par catégorie).
- recipe-tech-step-correction.service.ts : applyManualCorrection écrit
désormais StepTechStepTemperature (delete+recreate, même logique "le
manuel remplace tout" que les deux autres tables). raw n'est jamais
envoyé par le client — dérivé de description.slice(start, end), pour ne
pas faire confiance à une copie cliente qui pourrait diverger du texte
réel. loadVisibleStepOrThrow retourne désormais le texte complet de la
description (pas seulement sa longueur), nécessaire pour cette dérivation.
- specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux
routes de correction et step_tech_step_temperature n'étaient documentées
nulle part — comblé au passage.
- Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou
plusieurs champs renseignés, remplacement sans accumulation, dérivation
de raw, span hors limites).
Périmètre : back-end uniquement (voir la maquette proposée précédemment).
Le popover de correction (apps/web) ne sera mis à jour que dans une PR
suivante, empilée sur celle-ci.
Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas
de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cliquer sur une recette de l'onglet Recettes de /cuisiner naviguait vers
/recettes/:id, quittant la session de cuisine en cours. La ligne devient un
bouton qui déplie désormais le détail complet de la recette directement
sous elle (RecipeDetailPanel, showActions=false — même traitement "aperçu"
que RecipePickerDialog), chargé à la demande via GET /recipes/:id, un seul
dépliant ouvert à la fois (même logique que le sous-menu Paramètres de la
sidebar). RecipesTab est désormais keyed par semaine comme PlanTab, pour
que changer de semaine referme un dépliant devenu obsolète.
Clé i18n recipesTab.viewRecipe supprimée (plus d'appelant).
Tests : cooking-session-page.cy.ts (déplier/replier, un seul ouvert à la
fois) + cooking-session.feature (scénario dédié). pnpm lint + pnpm --filter
web build clean ; 16/16 tests passants (Chrome headless).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dans l'onglet Plan de /cuisiner, la liste de tâches d'une phase était un
flux plat — la recette concernée n'apparaissait qu'en petite ligne sous
chaque carte. Regroupe désormais les tâches par combinaison exacte de
recettes (groupTasksByRecipes, cooking-session.ts) : chaque groupe devient
une mini-section titrée par le(s) nom(s) de recette(s), un pool
merged-prep/merged-setup formant sa propre section partagée plutôt que
d'être rattaché à une seule recette. La ligne "pour X" par carte devient
redondante avec le titre de section et est retirée (clé i18n forRecipes
supprimée).
Couverture ajoutée : cooking-session-page.cy.ts (mode layout, 3 sections
distinctes sur la fixture à préchauffages multiples) + cooking-session.feature
(parcours Gherkin, section partagée + sections solo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`createRecipeInternal` écrivait, pour chaque technique détectée, ses
ingrédients / ustensiles / températures de clause ; `updateRecipe` et le
script `backfill-tech-steps.ts` n'écrivaient que le span de la technique.
Toute recette ré-enregistrée via l'UI (ou touchée par le backfill) perdait
donc silencieusement ces métadonnées — et `cooking-optimizer.ts` ne pouvait
plus détecter de conflit de four entre deux préchauffages dès qu'une recette
avait été éditée une fois (ni sur les imports antérieurs à la détection des
températures, jamais ré-enregistrés).
- `toStepTechStepCreateInputs` (nouveau, `lib/recipe-matching/`) : seule
source de vérité du payload `techSteps.create`, utilisée par les trois
appelants pour qu'ils ne divergent plus.
- `backfill-tech-steps.ts` : `createMany` -> `create` par ligne (createMany
ne fait pas les écritures imbriquées) + doc à jour.
- Rattrapage automatique au déploiement : `model BackfillState` + migration
`backfill_state`, script `backfill-runtime.ts` ajouté au CMD Docker après
`seed-runtime`. Gardé par un marqueur (tourne une seule fois), n'échoue
jamais le boot (une panne du backfill est loggée, le serveur démarre
quand même, retente au prochain start).
- Test `PATCH /recipes/:id` : une température de clause survit à l'édition.
- `reset-db.ts` : `backfill_state` ajouté au TRUNCATE.
Vérifié : `tsc --noEmit` + `pnpm --filter api build` + `pnpm lint` OK ;
migration = SQL généré par Prisma pour le modèle, à l'identique. Mocha
`recipe.test.ts` non lancé (Postgres + intent-service requis, indisponibles
ici) — à jouer en CI / sur la machine de dev.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le premier essai (length=2048 + ngram_size=2) a fait chuter le F1 agrege de
`tech-step-eval.test.ts` de 0.815 a 0.753, sous le plancher de 0.8. Balayage
mesure (fr, 25 iters, ngram=1, jeu de dev = derniere utterance tenue hors
entrainement ; collisions = tokens distincts partageant un bucket, sur 920) :
length=262144 dev 97.3% conf 0.809 2/920 collisions 400 s <- defaut
length=65536 dev 97.3% conf 0.809 10/920 collisions 106 s <- retenu
length=16384 dev 97.3% conf 0.808 50/920 collisions 35 s
length=2048 dev 97.3% conf 0.816 305/920 collisions 13 s <- livre puis recule
Deux corrections :
- ngram_size 2 -> 1. Les bigrammes rendent le classifieur nettement plus
confiant (0.809 -> 0.919), ce qui degrade la justesse bout-en-bout :
`_classifyClause` (tech-step-matcher.ts) ne retombe sur l'ancre NER que
sous CONFIDENCE_THRESHOLD, donc une classification confiante mais fausse
sur une technique voisine (blindBake au lieu de bake, poach au lieu de
boil) passe le seuil et ecrase l'ancre correcte. C'etait la seule cause
de la chute du F1.
- length 2048 -> 65536. A 2048, 305/920 tokens entrent en collision (33 %)
et la confiance moyenne se decale (0.816 vs 0.809). 65536 : 10 collisions,
confiance identique au bit pres au defaut spaCy, memes verdicts sur les
probes difficiles. ~5x plus rapide (vs ~30x pour 2048), 15x moins de
parametres. Descendre plus bas est possible mais reste du travail pour la
phase ou le jeu d'evaluation aura ete elargi et pourra le valider.
initialize() fr+en (chargement + entrainement) : 208.9 s contre ~930 s.
Suite pytest : 46 passed en 219.8 s (l'essentiel est le fixture qui entraine
le vrai corpus des deux locales, session-scoped).
start_period / timeout d'attente : 300s -> 600s (plafond pour runner lent).
Non verifie ici : le gate F1 lui-meme (tech-step-eval.test.ts, Postgres +
service requis, pas de Docker dans cet environnement) -> CI. Les 6 autres
echecs du job `test` (resetDatabase / seedReferenceData : contrainte unique
sur `key`, timeouts de hook) sont pre-existants sur `main` @ 81f7cb9 et sans
rapport avec ce changement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le service mettait ~930 s (15-16 min) a devenir `healthy`, ce que le README
presentait comme "un compromis assume, pas un defaut de configuration a
corriger". Mauvais diagnostic : le cout ne venait ni des iterations ni de la
taille du corpus, mais du parametre `length` de `spacy.TextCatBOW.v3`, laisse
a son defaut spaCy de 262144 faute d'etre passe explicitement.
Avec `no_output_layer: False` et 74 techniques, la couche de sortie faisait
19 398 730 parametres mis a jour par Adam a chaque pas de gradient, pour une
table de hachage vide a 99 % (le corpus tient dans quelques milliers de
tokens distincts).
Mesure contre le vrai corpus (fr, 25 iterations, jeu de dev = derniere
utterance de chaque technique tenue hors entrainement) :
length=262144 ngram=1 dev 97.3% conf 0.809 394.7 s <- avant
length=2048 ngram=2 dev 97.3% conf 0.919 12.3 s <- apres
Meme accuracy, meilleure confiance, 32x plus rapide. Les bigrammes profitent
des locutions du corpus ("faire fondre", "laisser mijoter") qu'un sac
d'unigrammes ne distingue pas de leurs constituants isoles.
PipelineRegistry.initialize() (fr+en, chargement + entrainement) mesure a
28,5 s contre ~930 s. Suite pytest complete : 46 passed en 40,6 s.
- locale_pipeline.py : `_TEXTCAT_MODEL_LENGTH` / `_TEXTCAT_NGRAM_SIZE`
explicites et commentes avec les mesures ; historique de calibration de
`_TRAINING_ITERATIONS` corrige (il attribuait le cout aux iterations)
- README : sections "Temps de demarrage" et "Limitations connues" reecrites
(la couverture d'evaluation, 26/74 techniques, y devient la limitation
bloquante ; ajout des collisions de classes observees)
- docker-compose.yml / ci.yml : commentaires citant ~930 s corriges,
start_period et timeout 1200s -> 300s (plafond pour runner lent, pas cible)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant
aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais
seulement quand elles sont compatibles : deux préchauffages ne fusionnent
que s'ils visent la même température détectée. Deux préchauffages à des
températures différentes restent séparés, ordonnés du plus froid au plus
chaud, et la mise en place porte une note « Four requis à N températures —
enchaîner … ». Défaut optimiste : deux préchauffages sans température
détectée fusionnent quand même (supposés compatibles).
- cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune
conversion inter-représentations), isPureSetupStep / setupSignature /
extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour
l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes
- cooking-session.service.ts : `temperatures` dans l'include + mapping
- packages/shared : CookingTemperatureView, kind « merged-setup »,
CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes
- web : temperature-label.ts extrait de StepDescription et réutilisé ;
taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note
- i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.*
- tests : 7 cas Mocha « setup pooling » + test DB cooking-session,
cas Cypress mode layout + scénario Gherkin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remanie /cuisiner : au lieu d'empiler toutes les phases, deux onglets
(Recettes, Plan) et un stepper qui parcourt les phases une à une. Chaque
tâche est cochable ; la progression est mémorisée par semaine dans
localStorage — aucun état serveur, même parti pris que le reste de la page.
- CookingSessionPage : barre d'onglets + PlanTab (stepper, nav
précédent/suivant, clic direct sur un nœud) + RecipesTab (liste des
recettes de la semaine, lien vers la fiche)
- cooking-progress.ts : CookingProgressStore (load/save localStorage,
clé batchcooking:cookingProgress:<semaine>)
- cooking-session.ts : reconcileChecked / phaseProgress (helpers purs)
- réutilise CheckboxOption pour les cartes de tâche cochables
- i18n : cookingSession.tabs / stepper / progress / recipesTab
- tests Cypress (mode layout + Gherkin) mis à jour et étendus
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le runner Forgejo `act` ne garde pas un conteneur `services:` comme *la
meme* instance d'une etape a l'autre : `prisma migrate deploy` posait le
schema dans un Postgres, puis le `beforeAll` de Mocha en frappait un vide
(« table … does not exist » juste apres « migrations successfully
applied »). Trois occurrences en une journee, dont une sur `main` apres le
merge de #18 — code identique a `ccec30b` qui, lui, etait vert.
Le job `test` demarre desormais Postgres lui-meme via `docker run -d`
(socket docker deja monte dans le conteneur de job), sur le reseau du job
decouvert dynamiquement, joignable comme `postgres` — exactement la meme
posture « vraie dependance lancee par le job » que l'etape uvicorn de
`tech-step-intent-service` juste en dessous, que `services:` ne pouvait
deja pas couvrir. Un seul conteneur, partage par toutes les etapes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le job `test` du run precedent a echoue sur un flake du conteneur Postgres
`services:` du runner act (`relation "user_profile_allergy" does not exist`
juste apres « All migrations successfully applied », 0 test execute). Aucun
fichier lie a la DB n'a change dans cette PR, et le superset feat/temperature-
metadata a fait tourner `test` vert (516 passing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI: 7 `deep.equal` sur un `TechStepMatch` / `StepTechStepView` echouaient
(l'objet reel a une cle de plus depuis l'ajout de `temperatures`).
- tech-step-matcher.test.ts x4 : `temperatures: []`, sauf le cas
« Faire mijoter a feu doux » ou « feu doux » [16,24) est une mention
qualitative -> `qualitative: "low"`.
- recipe-tech-step-correction.test.ts x3 : `temperatures: []` (l'etape
fixture « Faire mijoter la sauce. » n'a pas de mention de temperature).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).
services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.
apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
(cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
reset-db.ts). Persistance + lecture dans `recipe.service.ts`
(`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
du service de correction ; `sources.service.ts` (apercu d'import) les
fait transiter.
packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.
apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
redecoupe les segments non-keyword autour des spans de temperature (la
logique technique intriquee reste intacte). `?? []` tolere une payload
d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
`temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
ustensiles).
Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La route page `/admin/monitoring` et le chemin API `GET /admin/monitoring`
sont identiques : un glob `**/admin/monitoring` capturait aussi la requete
document du `cy.visit()`. Le hardcode `http://localhost:3000/...` marchait
en local (VITE_API_URL dans apps/web/.env) mais pas en CI (pas de .env ->
API en meme origine sur :5173, donc collision totale) : `cy.wait
(@getMonitoring)` timeout.
`resourceType: "fetch"` epingle l'intercept sur le seul XHR d'AdminApiClient.
Verifie en simulant la CI (sans apps/web/.env) : 16/16 specs admin verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ces suites ont ete ecrites pendant le dev des features cooking / admin /
hors-catalogue mais jamais executees (pas de Postgres dans ces sessions).
Leur 1re execution reelle sur `main` echouait — bugs dans les tests, pas
dans le code merge.
- reference.test.ts : `GET /reference/ingredients` renvoie desormais
`isPlaceholder` (toujours false) et `displayName` (toujours null) depuis
la PR #16 (champs de `IngredientView`). L'assertion `to.have.keys([...])`
exacte est mise a jour.
- cooking-session.test.ts : la fixture "pooling merged-prep" avait 2
recettes symetriques (chop -> simmer) ; apres mise en commun du chop les
deux simmer tournent dans l'unique phase de cuisson, donc aucun
`background` possible (l'optimiseur est correct, cf. le test pur
equivalent). « Tarte » recoit une etape active `mix` de plus pour que son
simmer flotte en background pendant que « Soupe » est en hold.
- admin-tech-steps.test.ts : une requete supertest ne part qu'a l'`await`/
`.then` ; la 1re requete /retrain concurrente n'etait jamais lancee, donc
le verrou process n'etait jamais tenu et la 2e recevait 200 au lieu de
409. Ajout d'un `.then(res => res, err => err)` pour la declencher avant
l'attente de 100 ms.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.
Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.
Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.
Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR 4 du chantier admin. Board de sante temps reel des dependances.
- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
"tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
ne throw jamais) appele au boot (index.ts), a chaque tick et apres
chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
tech-step-intent-service (/health), et le worker via son heartbeat.
Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
apps/admin-web/cypress/{screenshots,videos,downloads}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement
quand la semaine affichee contient >= 1 recette ; navigue vers
/cuisiner?date=<semaine>.
- Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend
les phases (mise en place / cuisson / dressage), les taches mutualisees
avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande
"Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=,
WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de
courses).
- Logique pure extraite dans cooking-session.ts (composition des libelles,
formatage des quantites).
- i18n : bloc cookingSession.* + planning.startCooking.
- apiClient.getCookingPlanForWeek.
- Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session
.feature (parcours planning -> /cuisiner), assertions bouton dans
planning-page.cy.ts ; step generique "button should be disabled" mutualise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Module cooking-session : charge le Planning couvrant ?date= (meme requete
"plage couvrante" + degradation "jamais null" que /shopping-list), mappe
chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/
unites/techniques/ustensiles resolus via toIngredientView/toUnitView
reutilisees de recipe.service), renvoie OptimizedCookingPlanView.
- cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude.
- Route requireAuth, contrat ?date= identique a /shopping-list.
- Monte /cooking-session dans app.ts.
- Tests d'integration Mocha (401, date invalide, plan vide sans foyer /
sans planning, mutualisation d'une decoupe entre 2 recettes planifiees).
- specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO
-> v1 implementee ; nouvelle section dans backend-architecture.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.
- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
poussees en tache de fond des phases suivantes pendant qu'une autre recette
avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
ingredient-matcher / tech-step-matcher), testable en isolation.
Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
/** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
interfaceNormalizedStep{
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string;
recipeIndex: number;
recipe: CookingSessionRecipeRef;
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
attention: Attention;
isPurePrep: boolean;
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
isPureSetup: boolean;
dominantTechnique: TechStepView|null;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
temperatures: CookingTemperatureView[];
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
absorbed: boolean;
}
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
exportinterfaceIntentServiceProcessResult{
entities: IntentServiceEntity[];
intent: string|null;
score: number;
temperatures: IntentServiceTemperature[];
}
/**
@ -88,10 +105,26 @@ export class IntentServiceClient {
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
/** Router mounted at `/admin/api/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
typeUserRow=Awaited<
ReturnType<
typeofprisma.userProfile.findFirstOrThrow<{
include:{house:{select:{name: true}}};
}>
>
>;
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
/** Router mounted at `/profile` in app.ts. Every route requires a session — this is the authenticated user's own profile. */
exportconstprofileRouter=Router();
/** Express types a route param `string | undefined` even for a segment that always matches when the route does — satisfies TS, unreachable in practice. */
// No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are
@ -26,7 +30,7 @@ import {
// is a parameter rather than baked into that module.
constDEFAULT_TECH_STEP_LOCALE="fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */
functionrecipeInclude(viewerId: number){
return{
ingredients:{
@ -66,22 +70,43 @@ function recipeInclude(viewerId: number) {
/** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
/** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */
@ -126,10 +153,13 @@ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] {
return[...byId.values()];
}
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. */
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */
/** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */
throwerr;// see suitableForHouseholdWhere()'s catch comment above
throwerr;// see suitableForHousehold()'s catch comment above
}
}
@ -359,11 +426,11 @@ async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.Rec
exportinterfaceListRecipesFilters{
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
/** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */
dietIds?: number[];
}
@ -383,31 +450,32 @@ export async function listRecipes(
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */
interfaceRunningTotal{
ingredient: IngredientView;
unit: UnitView;
quantity: number;
unit: UnitView|null;
quantity: number|null;
}
/**
@ -65,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin