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:
Arnaud
2026-07-22 00:36:10 +02:00
parent 35d4f3d055
commit 592c86f8d4
11 changed files with 202 additions and 9 deletions
+14 -1
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { NutritionDiary } from "@/components/nutrition/nutrition-diary";
import { NutritionTrend } from "@/components/nutrition/nutrition-trend";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -17,7 +19,18 @@ export default async function NutritionDiaryPage() {
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
</div>
<NutritionDiary />
<Tabs defaultValue="diary" className="gap-6">
<TabsList>
<TabsTrigger value="diary">{m.nutritionDiary.tabDiary}</TabsTrigger>
<TabsTrigger value="trend">{m.nutritionDiary.tabTrend}</TabsTrigger>
</TabsList>
<TabsContent value="diary">
<NutritionDiary />
</TabsContent>
<TabsContent value="trend">
<NutritionTrend />
</TabsContent>
</Tabs>
</div>
);
}
@@ -6,11 +6,19 @@ function isValidDate(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(new Date(`${value}T00:00:00.000Z`).getTime());
}
const VALID_RANGES = [7, 30, 90];
export async function GET(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const userId = session!.user.id;
const rangeParam = req.nextUrl.searchParams.get("range");
const range = rangeParam ? parseInt(rangeParam, 10) : null;
if (range && VALID_RANGES.includes(range)) {
return getTrend(userId, range);
}
const dateParam = req.nextUrl.searchParams.get("date");
const date = dateParam && isValidDate(dateParam) ? dateParam : new Date().toISOString().slice(0, 10);
@@ -101,3 +109,56 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ date, totals, goals, coverage, entries, unknownCount });
}
/** Multi-day trend: daily calorie/macro totals over the last N days, one
* bucket per calendar day (UTC, matching the single-day endpoint's own
* dayStart/dayEnd math above) — days with nothing cooked still appear with
* zero totals so the chart has a continuous x-axis. */
async function getTrend(userId: string, days: number): Promise<NextResponse> {
const todayStr = new Date().toISOString().slice(0, 10);
const since = new Date(`${todayStr}T00:00:00.000Z`);
since.setUTCDate(since.getUTCDate() - (days - 1));
const rows = await db
.select({
servings: cookingHistory.servings,
cookedAt: cookingHistory.cookedAt,
baseServings: recipes.baseServings,
nutritionData: recipes.nutritionData,
})
.from(cookingHistory)
.leftJoin(recipes, eq(cookingHistory.recipeId, recipes.id))
.where(and(eq(cookingHistory.userId, userId), gte(cookingHistory.cookedAt, since)));
const buckets = new Map<string, { calories: number; proteinG: number; carbsG: number; fatG: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setUTCDate(d.getUTCDate() + i);
buckets.set(d.toISOString().slice(0, 10), { calories: 0, proteinG: 0, carbsG: 0, fatG: 0 });
}
for (const row of rows) {
const perServing = row.nutritionData?.perServing;
if (!perServing) continue;
const servings = row.servings ?? row.baseServings ?? 1;
const key = row.cookedAt.toISOString().slice(0, 10);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.calories += perServing.calories * servings;
bucket.proteinG += perServing.proteinG * servings;
bucket.carbsG += perServing.carbsG * servings;
bucket.fatG += perServing.fatG * servings;
}
const goalsRow = await db.query.userNutritionGoals.findFirst({ where: eq(userNutritionGoals.userId, userId) });
const daysOut = [...buckets.entries()].map(([date, totals]) => ({
date,
calories: Math.round(totals.calories),
proteinG: Math.round(totals.proteinG),
carbsG: Math.round(totals.carbsG),
fatG: Math.round(totals.fatG),
}));
return NextResponse.json({ range: days, days: daysOut, goalCalories: goalsRow?.caloriesKcal ?? null });
}
@@ -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>
);
}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.68.0";
export const APP_VERSION = "0.69.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.69.0",
date: "2026-07-22 09:00",
added: [
"Nutrition page gets a Trend tab alongside the daily Diary: a 7/30/90-day calorie chart plus daily protein/carbs/fat averages, computed from the same cooking history the diary already tracks.",
],
},
{
version: "0.68.0",
date: "2026-07-21 10:00",
+7 -1
View File
@@ -511,6 +511,12 @@ export function generateOpenApiSpec(): object {
date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
coverage: NutritionTotalsRef, entries: z.array(NutritionDiaryEntryRef), unknownCount: z.number().int(),
}));
const NutritionTrendRef = registry.register("NutritionTrend", z.object({
range: z.number().int(), goalCalories: z.number().int().nullable(),
days: z.array(z.object({
date: z.string(), calories: z.number().int(), proteinG: z.number().int(), carbsG: z.number().int(), fatG: z.number().int(),
})),
}));
const UserSearchResultRef = registry.register("UserSearchResult", z.object({
id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
}));
@@ -528,7 +534,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC)") }) }, responses: { 200: { description: "Diary", content: { "application/json": { schema: NutritionDiaryRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
+6 -1
View File
@@ -686,7 +686,12 @@
"noEntries": "Nothing logged for this day. Mark a recipe as \"cooked\" to see it here.",
"servingsLabel": "{count, plural, one {1 serving} other {{count} servings}}",
"nutritionUnknownBadge": "Nutrition unknown",
"unknownNote": "{count, plural, one {1 entry has no nutrition data and isn't included in the totals above.} other {{count} entries have no nutrition data and aren't included in the totals above.}}"
"unknownNote": "{count, plural, one {1 entry has no nutrition data and isn't included in the totals above.} other {{count} entries have no nutrition data and aren't included in the totals above.}}",
"tabDiary": "Diary",
"tabTrend": "Trend",
"trendCaloriesTitle": "Calories over time",
"trendRangeDays": "{count} days",
"trendDailyAvg": "avg {value}/day"
},
"explore": {
"title": "Explore",
+6 -1
View File
@@ -686,7 +686,12 @@
"noEntries": "Rien n'est enregistré pour ce jour. Marquez une recette comme « cuisinée » pour la voir ici.",
"servingsLabel": "{count, plural, one {1 portion} other {{count} portions}}",
"nutritionUnknownBadge": "Nutrition inconnue",
"unknownNote": "{count, plural, one {1 entrée n'a pas de données nutritionnelles et n'est pas incluse dans les totaux ci-dessus.} other {{count} entrées n'ont pas de données nutritionnelles et ne sont pas incluses dans les totaux ci-dessus.}}"
"unknownNote": "{count, plural, one {1 entrée n'a pas de données nutritionnelles et n'est pas incluse dans les totaux ci-dessus.} other {{count} entrées n'ont pas de données nutritionnelles et ne sont pas incluses dans les totaux ci-dessus.}}",
"tabDiary": "Journal",
"tabTrend": "Tendance",
"trendCaloriesTitle": "Calories dans le temps",
"trendRangeDays": "{count} jours",
"trendDailyAvg": "moy. {value}/jour"
},
"explore": {
"title": "Explorer",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.68.0",
"version": "0.69.0",
"private": true,
"scripts": {
"dev": "next dev",