diff --git a/CHANGELOG.md b/CHANGELOG.md
index eba7af9..761ea84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
+## 0.69.0 — 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.
+
## 0.68.0 — 2026-07-21 10:00
### Added
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
index 95d4599..d3bf77a 100644
--- a/FEATURE_AUDIT.md
+++ b/FEATURE_AUDIT.md
@@ -65,7 +65,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
| Nutrition goals | Exists | | `apps/web/app/api/v1/users/me/nutrition-goals` |
-| Nutrition diary | Partial | Single calendar day only — **no multi-day trend/history chart** | `apps/web/app/api/v1/users/me/nutrition-diary` |
+| Nutrition diary | Exists | Single-day diary view, plus a Trend tab (7/30/90-day calorie line chart + daily macro averages) — the same endpoint switches modes via a `range` query param | `apps/web/app/api/v1/users/me/nutrition-diary`, `apps/web/components/nutrition/nutrition-trend.tsx` |
| **Barcode/USDA nutrition-facts database** | **Missing** | Barcode scan only IDs the product name, doesn't pull nutrition facts; all nutrition numbers are AI-estimated, never database-sourced | — |
| Batch cooking | Exists | Shared prep steps grouped by which dish they serve, per-dish fridge/freezer countdown independent of the whole-batch pantry deduction | `apps/web/components/recipe/batch-cook-*.tsx` |
@@ -142,7 +142,7 @@ Ranked roughly by likely value:
1. **Stripe checkout + billing portal** — the webhook consumer is production-ready but nothing produces the checkout event or lets a user self-serve upgrade/cancel/view invoices. Biggest gap if monetizing seriously.
2. **Recipe video** — no support at all.
-3. **Nutrition trend/history view** — diary is single-day only, no multi-day chart.
+3. ~~Nutrition trend/history view~~ — closed 2026-07-22 (7/30/90-day calorie chart + macro averages).
4. **USDA/nutrition-database lookup** — all nutrition numbers are AI-estimated; barcode scan only gets product name, not nutrition facts.
5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
diff --git a/apps/web/app/(app)/nutrition/page.tsx b/apps/web/app/(app)/nutrition/page.tsx
index a22301f..3325f0c 100644
--- a/apps/web/app/(app)/nutrition/page.tsx
+++ b/apps/web/app/(app)/nutrition/page.tsx
@@ -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() {
{m.nutritionDiary.title}
{m.nutritionDiary.subtitle}
-
+
+
+ {m.nutritionDiary.tabDiary}
+ {m.nutritionDiary.tabTrend}
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/app/api/v1/users/me/nutrition-diary/route.ts b/apps/web/app/api/v1/users/me/nutrition-diary/route.ts
index 8ad5441..5b29376 100644
--- a/apps/web/app/api/v1/users/me/nutrition-diary/route.ts
+++ b/apps/web/app/api/v1/users/me/nutrition-diary/route.ts
@@ -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 {
+ 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();
+ 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 });
+}
diff --git a/apps/web/components/nutrition/nutrition-trend.tsx b/apps/web/components/nutrition/nutrition-trend.tsx
new file mode 100644
index 0000000..849f3a8
--- /dev/null
+++ b/apps/web/components/nutrition/nutrition-trend.tsx
@@ -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(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 (
+
+
+
{t("trendCaloriesTitle")}
+
+
+
+ {error &&
{t("loadError")}
}
+ {!error && data && (
+ <>
+
`${n} kcal`} dateFormat="day" />
+ {avg && (
+
+
+ {t("protein")}
+ {t("trendDailyAvg", { value: `${avg.proteinG}g` })}
+
+
+ {t("carbs")}
+ {t("trendDailyAvg", { value: `${avg.carbsG}g` })}
+
+
+ {t("fat")}
+ {t("trendDailyAvg", { value: `${avg.fatG}g` })}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts
index 2df2781..b0924d8 100644
--- a/apps/web/lib/changelog.ts
+++ b/apps/web/lib/changelog.ts
@@ -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",
diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts
index fc0037b..4e633a3 100644
--- a/apps/web/lib/openapi.ts
+++ b/apps/web/lib/openapi.ts
@@ -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 } } } } });
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 74090bb..5b554cb 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -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",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index edc7a36..dd70d61 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -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",
diff --git a/apps/web/package.json b/apps/web/package.json
index ccf66c3..eac4fa2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
- "version": "0.68.0",
+ "version": "0.69.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/package.json b/package.json
index 32f8147..6dba651 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "epicure",
- "version": "0.68.0",
+ "version": "0.69.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",