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
+63 -4
View File
@@ -7,6 +7,7 @@
"feed": "Feed",
"collections": "Collections",
"mealPlan": "Meal Plan",
"nutrition": "Nutrition",
"pantry": "Pantry",
"shopping": "Shopping",
"settings": "Settings",
@@ -303,7 +304,8 @@
},
"nutritionGoals": {
"title": "Daily Nutrition Goals",
"description": "Set your daily targets. These are shown as progress bars on your meal plan."
"description": "Set your daily targets. These are shown as progress bars on your meal plan.",
"viewDiaryCta": "View nutrition diary"
},
"webhooksPage": {
"title": "Webhooks",
@@ -398,6 +400,27 @@
"fiber": "Fiber",
"sodium": "Sodium"
},
"nutritionDiary": {
"title": "Nutrition Diary",
"subtitle": "Your daily nutrition intake, computed from what you've cooked.",
"dateLabel": "Date",
"today": "Today",
"loading": "Loading…",
"loadError": "Couldn't load your nutrition diary. Please try again.",
"goalNotSet": "You haven't set daily nutrition goals yet.",
"setGoalsCta": "Set goals",
"calories": "Calories",
"protein": "Protein",
"carbs": "Carbs",
"fat": "Fat",
"fiber": "Fiber",
"sodium": "Sodium",
"entriesTitle": "Recipes cooked",
"noEntries": "Nothing logged for this day. Mark a recipe as \"cooked\" to see it here.",
"servingsLabel": "{count, plural, one {1 serving} other {{count} servings}}",
"nutritionUnknownBadge": "Nutrition unknown",
"unknownNote": "{count, plural, one {1 entry has no nutrition data and isn't included in the totals above.} other {{count} entries have no nutrition data and aren't included in the totals above.}}"
},
"explore": {
"title": "Explore",
"searchPlaceholder": "Search public recipes…",
@@ -430,12 +453,11 @@
"copyMarkdown": "Copy as Markdown",
"downloadMarkdown": "Download as Markdown",
"copiedToClipboard": "Copied to clipboard",
"copyFailed": "Failed to copy",
"copyFailed": "Failed to copy to clipboard",
"print": "Print",
"share": "Share",
"deleteFailed": "Delete failed",
"updateFailed": "Update failed",
"copyFailed": "Failed to copy to clipboard",
"deleted": "Deleted",
"cancel": "Cancel",
"delete": "Delete",
@@ -646,6 +668,8 @@
"includePantryItems": "Include pantry items",
"pantryMode": "Pantry mode — maximize use of what you have",
"pantryModeDescription": "Prefer meals that use multiple pantry ingredients and minimize extra shopping.",
"targetNutritionGoals": "Target my nutrition goals",
"targetNutritionGoalsDescription": "Nudge the AI to aim for your daily calorie and macro targets across the day's meals.",
"removeFailed": "Failed to remove",
"generationFailed": "Generation failed",
"anyDifficulty": "Any",
@@ -691,7 +715,26 @@
"colItem": "Item",
"colQuantity": "Quantity",
"colExpires": "Expires",
"expiredDaysAgo": "Expired {days}d ago"
"expiredDaysAgo": "Expired {days}d ago",
"scan": "Scan",
"scanTitle": "Add by scan",
"scanDescription": "Scan a barcode or take a photo to quickly add pantry items.",
"scanBarcodeOption": "Scan barcode",
"scanPhotoOption": "Take/upload photo",
"scanBarcodeTitle": "Scan barcode",
"scanBarcodeDescription": "Point your camera at a product barcode.",
"scanConfirmTitle": "Confirm item",
"scanConfirmDescription": "Review and edit before adding to your pantry.",
"scanAnalyzingTitle": "Analyzing photo",
"scanAnalyzingDescription": "Identifying items in your photo…",
"scanBack": "Back",
"scanAddToPantry": "Add to pantry",
"scanAdded": "Added to pantry",
"scanLookupFailed": "Barcode lookup failed",
"scanNotFound": "Product not found. Try a photo scan instead.",
"scanPhotoFailed": "Photo scan failed",
"scanNoItemsFound": "No items recognized in this photo",
"cameraError": "Couldn't access the camera"
},
"feed": {
"title": "Feed",
@@ -984,5 +1027,21 @@
"privateBio": "AI context (private)",
"privateBioDescription": "Never shown publicly. Injected into AI prompts to personalise suggestions — add your dietary preferences, kitchen equipment, cooking skill level, allergies, etc.",
"privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…"
},
"profilePage": {
"tabRecipes": "Recipes",
"tabHistory": "Cooking History",
"tabPhotos": "Photos",
"statTotalCooked": "Total times cooked",
"statStreak": "Current streak",
"statLastCooked": "Last cooked",
"streakDays": "{count, plural, one {# day} other {# days}}",
"noneYet": "None yet",
"recentActivity": "Recent activity",
"noHistoryYet": "No cooking history yet.",
"noPhotosYet": "No photos yet.",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {page} of {total}"
}
}
+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}"
}
}
}