b0849c3989
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>
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|