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,69 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2 } from "lucide-react";
export function BarcodeScanner({
onDetected,
onError,
}: {
onDetected: (barcode: string) => void;
onError?: (message: string) => void;
}) {
const t = useTranslations("pantry");
const videoRef = useRef<HTMLVideoElement>(null);
const [starting, setStarting] = useState(true);
const detectedRef = useRef(false);
useEffect(() => {
let cancelled = false;
let controls: { stop: () => void } | null = null;
async function start() {
try {
const { BrowserMultiFormatReader } = await import("@zxing/browser");
const reader = new BrowserMultiFormatReader();
if (cancelled || !videoRef.current) return;
controls = await reader.decodeFromVideoDevice(
undefined,
videoRef.current,
(result) => {
if (result && !detectedRef.current) {
detectedRef.current = true;
onDetected(result.getText());
controls?.stop();
}
}
);
if (!cancelled) setStarting(false);
} catch (err) {
if (!cancelled) {
onError?.(err instanceof Error ? err.message : t("cameraError"));
setStarting(false);
}
}
}
void start();
return () => {
cancelled = true;
controls?.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
<video ref={videoRef} className="h-full w-full object-cover" muted playsInline />
{starting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 className="h-6 w-6 animate-spin text-white" />
</div>
)}
<div className="pointer-events-none absolute inset-x-8 top-1/2 h-16 -translate-y-1/2 rounded-md border-2 border-white/70" />
</div>
);
}