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
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type NutritionTotals = {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
sodiumMg: number;
};
type NutritionGoals = {
caloriesKcal: number | null;
proteinG: number | null;
carbsG: number | null;
fatG: number | null;
} | null;
type NutritionCoverage = {
calories: number;
protein: number;
carbs: number;
fat: number;
};
type DiaryEntry = {
id: string;
recipeId: string;
title: string;
servings: number;
cookedAt: string;
nutritionKnown: boolean;
};
type DiaryResponse = {
date: string;
totals: NutritionTotals;
goals: NutritionGoals;
coverage: NutritionCoverage;
entries: DiaryEntry[];
unknownCount: number;
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function NutritionDiary() {
const t = useTranslations("nutritionDiary");
const [date, setDate] = useState(todayIso());
// Tagged with the date it was fetched for, and set only from inside the
// fetch's own then/catch — never synchronously in the effect body — so we
// don't need a separate "loading" setState call at the top of the effect.
const [result, setResult] = useState<
{ date: string; ok: true; data: DiaryResponse } | { date: string; ok: false } | null
>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/v1/users/me/nutrition-diary?date=${date}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("request failed"))))
.then((json: DiaryResponse) => {
if (!cancelled) setResult({ date, ok: true, data: json });
})
.catch(() => {
if (!cancelled) setResult({ date, ok: false });
});
return () => {
cancelled = true;
};
}, [date]);
const loading = result === null || result.date !== date;
const error = !loading && result !== null && !result.ok;
const data = !loading && result !== null && result.ok ? result.data : null;
const bars = data
? [
{
label: t("calories"),
value: data.totals.calories,
goal: data.goals?.caloriesKcal ?? null,
unit: "kcal",
percentage: data.coverage.calories,
colorClass: "bg-orange-500",
},
{
label: t("protein"),
value: data.totals.proteinG,
goal: data.goals?.proteinG ?? null,
unit: "g",
percentage: data.coverage.protein,
colorClass: "bg-blue-500",
},
{
label: t("carbs"),
value: data.totals.carbsG,
goal: data.goals?.carbsG ?? null,
unit: "g",
percentage: data.coverage.carbs,
colorClass: "bg-green-500",
},
{
label: t("fat"),
value: data.totals.fatG,
goal: data.goals?.fatG ?? null,
unit: "g",
percentage: data.coverage.fat,
colorClass: "bg-yellow-500",
},
]
: [];
return (
<div className="space-y-6">
<div className="flex items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="diary-date">{t("dateLabel")}</Label>
<Input
id="diary-date"
type="date"
value={date}
max={todayIso()}
onChange={(e) => setDate(e.target.value || todayIso())}
className="w-auto"
/>
</div>
{date !== todayIso() && (
<button
type="button"
onClick={() => setDate(todayIso())}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
>
{t("today")}
</button>
)}
</div>
{loading && <p className="text-sm text-muted-foreground">{t("loading")}</p>}
{error && <p className="text-sm text-destructive">{t("loadError")}</p>}
{!loading && !error && data && (
<>
{!data.goals && (
<Card>
<CardContent className="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">{t("goalNotSet")}</p>
<Link
href="/settings/nutrition"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("setGoalsCta")}
</Link>
</CardContent>
</Card>
)}
{data.goals && (
<Card>
<CardContent className="space-y-3 py-4">
{bars.map((bar) =>
bar.goal == null ? null : (
<div key={bar.label} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{bar.label}</span>
<span className="text-muted-foreground">
{bar.value} / {bar.goal} {bar.unit}
</span>
</div>
<div className="h-2 w-full rounded bg-muted overflow-hidden">
<div
className={`h-full rounded ${bar.colorClass}`}
style={{ width: `${Math.min(bar.percentage, 100)}%` }}
/>
</div>
</div>
)
)}
<div className="grid grid-cols-2 gap-3 pt-2 text-sm sm:grid-cols-2">
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
<span className="text-muted-foreground">{t("fiber")}</span>
<span className="font-medium tabular-nums">{data.totals.fiberG}g</span>
</div>
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
<span className="text-muted-foreground">{t("sodium")}</span>
<span className="font-medium tabular-nums">{data.totals.sodiumMg}mg</span>
</div>
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t("entriesTitle")}</h3>
{data.entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noEntries")}</p>
) : (
<ul className="space-y-1.5">
{data.entries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<Link href={`/recipes/${entry.recipeId}`} className="hover:underline">
{entry.title}
</Link>
<span className="flex items-center gap-2 text-muted-foreground">
<span>{t("servingsLabel", { count: entry.servings })}</span>
{!entry.nutritionKnown && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{t("nutritionUnknownBadge")}
</span>
)}
</span>
</li>
))}
</ul>
)}
{data.unknownCount > 0 && (
<p className="text-xs text-muted-foreground">{t("unknownNote", { count: data.unknownCount })}</p>
)}
</div>
</>
)}
</div>
);
}