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:
@@ -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.
|
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
|
## 0.68.0 — 2026-07-21 10:00
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -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` |
|
| **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` |
|
| 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 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 | — |
|
| **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` |
|
| 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.
|
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.
|
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.
|
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).
|
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`).
|
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { NutritionDiary } from "@/components/nutrition/nutrition-diary";
|
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";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
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>
|
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
||||||
</div>
|
</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>
|
</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());
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
const userId = session!.user.id;
|
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 dateParam = req.nextUrl.searchParams.get("date");
|
||||||
const date = dateParam && isValidDate(dateParam) ? dateParam : new Date().toISOString().slice(0, 10);
|
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 });
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.68.0",
|
||||||
date: "2026-07-21 10:00",
|
date: "2026-07-21 10:00",
|
||||||
|
|||||||
@@ -511,6 +511,12 @@ export function generateOpenApiSpec(): object {
|
|||||||
date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
|
date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
|
||||||
coverage: NutritionTotalsRef, entries: z.array(NutritionDiaryEntryRef), unknownCount: z.number().int(),
|
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({
|
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(),
|
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: "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: "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: "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: "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: "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 } } } } });
|
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 } } } } });
|
||||||
|
|||||||
@@ -686,7 +686,12 @@
|
|||||||
"noEntries": "Nothing logged for this day. Mark a recipe as \"cooked\" to see it here.",
|
"noEntries": "Nothing logged for this day. Mark a recipe as \"cooked\" to see it here.",
|
||||||
"servingsLabel": "{count, plural, one {1 serving} other {{count} servings}}",
|
"servingsLabel": "{count, plural, one {1 serving} other {{count} servings}}",
|
||||||
"nutritionUnknownBadge": "Nutrition unknown",
|
"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": {
|
"explore": {
|
||||||
"title": "Explore",
|
"title": "Explore",
|
||||||
|
|||||||
@@ -686,7 +686,12 @@
|
|||||||
"noEntries": "Rien n'est enregistré pour ce jour. Marquez une recette comme « cuisinée » pour la voir ici.",
|
"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}}",
|
"servingsLabel": "{count, plural, one {1 portion} other {{count} portions}}",
|
||||||
"nutritionUnknownBadge": "Nutrition inconnue",
|
"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": {
|
"explore": {
|
||||||
"title": "Explorer",
|
"title": "Explorer",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.68.0",
|
"version": "0.69.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.68.0",
|
"version": "0.69.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user