feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans

Six M-sized items from HANDOFF.md's new-features backlog:

- Profile tabs: cooking-history stats (total cooked, last-cooked, streak)
  and a "cooked it" photo gallery, both owner-only
- Display-time unit conversion (metric<->imperial) for recipe ingredients,
  respecting each user's unitPref; original value always shown alongside
  the conversion
- Nutrition daily diary: per-day macro totals computed from cooking history
  x recipe nutritionData, compared against user goals
- Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key)
  with an AI-vision fallback for unbarcoded items, always confirm-before-
  insert, both paths tier/rate-limited like other AI features
- Weekly digest email: new followers/comments/ratings + trending recipes,
  sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron`
  compose service hitting a bearer-token-protected internal route
- Meal-plan generation can now target a user's nutrition goals as a
  prompt-level nudge (recipes are AI-invented, not DB-sourced, so this
  can't be a hard macro constraint)

Caught a real deploy-breaking issue while adding the cron stage: appending
it after `runner` silently changed the Dockerfile's default build target,
and `web`'s compose config didn't pin one — fixed by pinning `target:
runner` explicitly. Verified with typecheck, lint, and three separate
`docker build --target` runs (runner/cron/migrator) plus `docker compose
config` validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 08:06:28 +02:00
parent 913410dbf3
commit b0849c3989
40 changed files with 1964 additions and 112 deletions
+64 -5
View File
@@ -7,6 +7,7 @@
"feed": "Fil d'actualité",
"collections": "Collections",
"mealPlan": "Planning repas",
"nutrition": "Nutrition",
"pantry": "Garde-manger",
"shopping": "Courses",
"settings": "Paramètres",
@@ -303,7 +304,8 @@
},
"nutritionGoals": {
"title": "Objectifs nutritionnels quotidiens",
"description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas."
"description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas.",
"viewDiaryCta": "Voir le journal nutritionnel"
},
"webhooksPage": {
"title": "Webhooks",
@@ -398,6 +400,27 @@
"fiber": "Fibres",
"sodium": "Sodium"
},
"nutritionDiary": {
"title": "Journal nutritionnel",
"subtitle": "Votre apport nutritionnel quotidien, calculé à partir de ce que vous avez cuisiné.",
"dateLabel": "Date",
"today": "Aujourd'hui",
"loading": "Chargement…",
"loadError": "Impossible de charger votre journal nutritionnel. Veuillez réessayer.",
"goalNotSet": "Vous n'avez pas encore défini d'objectifs nutritionnels quotidiens.",
"setGoalsCta": "Définir les objectifs",
"calories": "Calories",
"protein": "Protéines",
"carbs": "Glucides",
"fat": "Lipides",
"fiber": "Fibres",
"sodium": "Sodium",
"entriesTitle": "Recettes cuisinées",
"noEntries": "Rien n'est enregistré pour ce jour. Marquez une recette comme « cuisinée » pour la voir ici.",
"servingsLabel": "{count, plural, one {1 portion} other {{count} portions}}",
"nutritionUnknownBadge": "Nutrition inconnue",
"unknownNote": "{count, plural, one {1 entrée n'a pas de données nutritionnelles et n'est pas incluse dans les totaux ci-dessus.} other {{count} entrées n'ont pas de données nutritionnelles et ne sont pas incluses dans les totaux ci-dessus.}}"
},
"explore": {
"title": "Explorer",
"searchPlaceholder": "Rechercher des recettes publiques…",
@@ -430,12 +453,11 @@
"copyMarkdown": "Copier en Markdown",
"downloadMarkdown": "Télécharger en Markdown",
"copiedToClipboard": "Copié dans le presse-papiers",
"copyFailed": "Échec de la copie",
"copyFailed": "Échec de la copie dans le presse-papiers",
"print": "Imprimer",
"share": "Partager",
"deleteFailed": "Échec de la suppression",
"updateFailed": "Échec de la mise à jour",
"copyFailed": "Échec de la copie dans le presse-papiers",
"deleted": "Supprimé",
"cancel": "Annuler",
"delete": "Supprimer",
@@ -634,6 +656,8 @@
"includePantryItems": "Inclure les articles du garde-manger",
"pantryMode": "Mode garde-manger — maximiser ce que vous avez",
"pantryModeDescription": "Préférer les repas utilisant plusieurs ingrédients du garde-manger et limiter les achats supplémentaires.",
"targetNutritionGoals": "Viser mes objectifs nutritionnels",
"targetNutritionGoalsDescription": "Inciter l'IA à viser vos objectifs quotidiens de calories et de macronutriments sur l'ensemble des repas de la journée.",
"removeFailed": "Échec de la suppression",
"generationFailed": "Échec de la génération",
"anyDifficulty": "Toute",
@@ -679,7 +703,26 @@
"colItem": "Article",
"colQuantity": "Quantité",
"colExpires": "Expire",
"expiredDaysAgo": "Expiré il y a {days}j"
"expiredDaysAgo": "Expiré il y a {days}j",
"scan": "Scanner",
"scanTitle": "Ajouter par scan",
"scanDescription": "Scannez un code-barres ou prenez une photo pour ajouter rapidement des articles.",
"scanBarcodeOption": "Scanner un code-barres",
"scanPhotoOption": "Prendre/importer une photo",
"scanBarcodeTitle": "Scanner un code-barres",
"scanBarcodeDescription": "Pointez votre caméra vers un code-barres.",
"scanConfirmTitle": "Confirmer l'article",
"scanConfirmDescription": "Vérifiez et modifiez avant d'ajouter au garde-manger.",
"scanAnalyzingTitle": "Analyse de la photo",
"scanAnalyzingDescription": "Identification des articles dans votre photo…",
"scanBack": "Retour",
"scanAddToPantry": "Ajouter au garde-manger",
"scanAdded": "Ajouté au garde-manger",
"scanLookupFailed": "Échec de la recherche du code-barres",
"scanNotFound": "Produit introuvable. Essayez un scan photo.",
"scanPhotoFailed": "Échec du scan photo",
"scanNoItemsFound": "Aucun article reconnu sur cette photo",
"cameraError": "Impossible d'accéder à la caméra"
},
"feed": {
"title": "Fil d'actualité",
@@ -972,5 +1015,21 @@
"privateBio": "Contexte IA (privé)",
"privateBioDescription": "Jamais visible publiquement. Injecté dans les prompts IA pour personnaliser les suggestions — ajoutez vos préférences alimentaires, équipements, niveau de cuisine, allergies, etc.",
"privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…"
},
"profilePage": {
"tabRecipes": "Recettes",
"tabHistory": "Historique de cuisine",
"tabPhotos": "Photos",
"statTotalCooked": "Total de fois cuisinées",
"statStreak": "Série en cours",
"statLastCooked": "Dernière cuisson",
"streakDays": "{count, plural, one {# jour} other {# jours}}",
"noneYet": "Aucune pour le moment",
"recentActivity": "Activité récente",
"noHistoryYet": "Aucun historique de cuisine pour le moment.",
"noPhotosYet": "Aucune photo pour le moment.",
"previous": "Précédent",
"next": "Suivant",
"pageOf": "Page {page} sur {total}"
}
}
}