592c86f8d4
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>
92 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|