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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user