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,195 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations, useLocale } from "next-intl";
import { Flame, ChefHat, History } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { getPublicUrl } from "@/lib/storage";
export type HistoryEntry = {
id: string;
recipeId: string;
recipeTitle: string;
cookedAt: string;
servings: number | null;
};
export type HistoryData = {
totalCooked: number;
streak: number;
lastCooked: { recipeId: string; recipeTitle: string; cookedAt: string } | null;
recentEntries: HistoryEntry[];
};
export type PhotoItem = {
id: string;
recipeId: string;
recipeTitle: string;
photoKey: string;
createdAt: string;
};
export type PhotosData = {
items: PhotoItem[];
page: number;
totalPages: number;
total: number;
};
function StatCard({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="rounded-xl border bg-card p-4 space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
export function ProfileTabs({
username,
defaultTab,
recipesContent,
history,
photos,
}: {
username: string;
defaultTab: string;
recipesContent: React.ReactNode;
history: HistoryData;
photos: PhotosData;
}) {
const t = useTranslations("profilePage");
const locale = useLocale();
return (
<Tabs defaultValue={defaultTab} className="gap-6">
<TabsList>
<TabsTrigger value="recipes">{t("tabRecipes")}</TabsTrigger>
<TabsTrigger value="history">{t("tabHistory")}</TabsTrigger>
<TabsTrigger value="photos">{t("tabPhotos")}</TabsTrigger>
</TabsList>
<TabsContent value="recipes">{recipesContent}</TabsContent>
<TabsContent value="history">
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
label={t("statTotalCooked")}
value={
<span className="inline-flex items-center gap-1.5">
<ChefHat className="h-5 w-5 text-primary" />
{history.totalCooked}
</span>
}
/>
<StatCard
label={t("statStreak")}
value={
<span className="inline-flex items-center gap-1.5">
<Flame className="h-5 w-5 text-orange-500" />
{t("streakDays", { count: history.streak })}
</span>
}
/>
<StatCard
label={t("statLastCooked")}
value={
history.lastCooked ? (
<Link href={`/recipes/${history.lastCooked.recipeId}`} className="text-base font-medium hover:underline line-clamp-1">
{history.lastCooked.recipeTitle}
</Link>
) : (
<span className="text-base font-normal text-muted-foreground">{t("noneYet")}</span>
)
}
/>
</div>
{history.recentEntries.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">{t("recentActivity")}</h3>
<div className="rounded-xl border divide-y overflow-hidden">
{history.recentEntries.map((entry) => (
<Link
key={entry.id}
href={`/recipes/${entry.recipeId}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<History className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-sm font-medium truncate">{entry.recipeTitle}</span>
<span className="text-xs text-muted-foreground shrink-0">
{new Date(entry.cookedAt).toLocaleDateString(locale)}
</span>
</Link>
))}
</div>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">{t("noHistoryYet")}</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="photos">
{photos.items.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.items.map((photo) => (
<Link
key={photo.id}
href={`/recipes/${photo.recipeId}`}
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
>
<div className="relative aspect-square bg-muted overflow-hidden">
<Image
src={getPublicUrl(photo.photoKey)}
alt={photo.recipeTitle}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<div className="p-2">
<p className="text-sm font-medium leading-tight line-clamp-2">{photo.recipeTitle}</p>
</div>
</Link>
))}
</div>
{photos.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{photos.page > 1 && (
<Link
href={`/u/${username}?tab=photos&ppage=${photos.page - 1}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
{t("previous")}
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
{t("pageOf", { page: photos.page, total: photos.totalPages })}
</span>
{photos.page < photos.totalPages && (
<Link
href={`/u/${username}?tab=photos&ppage=${photos.page + 1}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
{t("next")}
</Link>
)}
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">{t("noPhotosYet")}</p>
</div>
)}
</TabsContent>
</Tabs>
);
}