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>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user