Files
Epicure/apps/web/components/nutrition/nutrition-trend.tsx
T
Arnaud 592c86f8d4 feat: nutrition trend/history view (v0.69.0)
Extends GET /api/v1/users/me/nutrition-diary with a `range` query
param (7/30/90) that switches it into trend mode -- daily
calorie/macro totals bucketed from the same cooking-history rows the
single-day diary already reads, with zero-filled days so the chart
has a continuous x-axis. New NutritionTrend component reuses the
existing hand-rolled TimeSeriesChart (previously admin-only, now
imported from user-facing code too) for the calorie line, plus
simple average-macro stat tiles below it.

Nutrition page now has Diary/Trend tabs instead of just the diary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 00:36:10 +02:00

92 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { TimeSeriesChart, type TimeSeriesPoint } from "@/components/admin/charts/time-series-chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type TrendDay = { date: string; calories: number; proteinG: number; carbsG: number; fatG: number };
type TrendResponse = { range: number; days: TrendDay[]; goalCalories: number | null };
const RANGES = [7, 30, 90] as const;
export function NutritionTrend() {
const t = useTranslations("nutritionDiary");
const [range, setRange] = useState<number>(30);
// Tagged with the range it was fetched for, and set only from inside the
// fetch's own then/catch — same pattern as nutrition-diary.tsx — so no
// separate setState call is needed synchronously in the effect body.
const [result, setResult] = useState<
{ range: number; ok: true; data: TrendResponse } | { range: number; ok: false } | null
>(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/v1/users/me/nutrition-diary?range=${range}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("request failed"))))
.then((json: TrendResponse) => { if (!cancelled) setResult({ range, ok: true, data: json }); })
.catch(() => { if (!cancelled) setResult({ range, ok: false }); });
return () => { cancelled = true; };
}, [range]);
const error = result !== null && result.range === range && !result.ok;
const data = result !== null && result.range === range && result.ok ? result.data : null;
const caloriePoints: TimeSeriesPoint[] = data?.days.map((d) => ({ date: d.date, value: d.calories })) ?? [];
const avg = data && data.days.length > 0
? {
proteinG: Math.round(data.days.reduce((s, d) => s + d.proteinG, 0) / data.days.length),
carbsG: Math.round(data.days.reduce((s, d) => s + d.carbsG, 0) / data.days.length),
fatG: Math.round(data.days.reduce((s, d) => s + d.fatG, 0) / data.days.length),
}
: null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">{t("trendCaloriesTitle")}</h3>
<Select value={String(range)} onValueChange={(v) => setRange(Number(v))}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGES.map((r) => (
<SelectItem key={r} value={String(r)}>{t("trendRangeDays", { count: r })}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{t("loadError")}</p>}
{!error && data && (
<>
<TimeSeriesChart data={caloriePoints} formatValue={(n) => `${n} kcal`} dateFormat="day" />
{avg && (
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
<span className="text-muted-foreground">{t("protein")}</span>
<span className="font-medium tabular-nums">{t("trendDailyAvg", { value: `${avg.proteinG}g` })}</span>
</div>
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
<span className="text-muted-foreground">{t("carbs")}</span>
<span className="font-medium tabular-nums">{t("trendDailyAvg", { value: `${avg.carbsG}g` })}</span>
</div>
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
<span className="text-muted-foreground">{t("fat")}</span>
<span className="font-medium tabular-nums">{t("trendDailyAvg", { value: `${avg.fatG}g` })}</span>
</div>
</div>
)}
</>
)}
</div>
);
}