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",