diff --git a/.dockerignore b/.dockerignore index f3b3b8e..6e57717 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ .env* !.env.example docker +!docker/cron *.md diff --git a/.env.example b/.env.example index de215bf..c578d1f 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,10 @@ VAPID_PRIVATE_KEY= # Stripe (optional — webhook stub only) STRIPE_WEBHOOK_SECRET= +# Shared secret for internal cron-triggered endpoints (e.g. weekly digest email). +# Generate with: openssl rand -base64 32 +CRON_SECRET= + # AI Providers (configure at least one) OPENROUTER_API_KEY= OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5 diff --git a/Dockerfile b/Dockerfile index 5ccd131..39ce4de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,3 +56,14 @@ COPY --from=build --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/. USER nextjs EXPOSE 3000 CMD ["node", "apps/web/server.js"] + +# ---- cron: periodically triggers internal cron endpoints (e.g. weekly digest) ---- +# Minimal alpine + busybox crond image — just curls the running `web` service on a +# schedule. Doesn't need the app build output at all, so it's based on `base` +# rather than `runner`, keeping the image tiny and independent of the Next build. +FROM base AS cron +RUN apk add --no-cache curl +COPY docker/cron/crontab /etc/crontabs/root +COPY docker/cron/run-digest.sh /usr/local/bin/run-digest.sh +RUN chmod +x /usr/local/bin/run-digest.sh +CMD ["crond", "-f", "-l", "2"] diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 331884f..87f47c8 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import Link from "next/link"; import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react"; import { auth } from "@/lib/auth/server"; -import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db"; +import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db"; import { buttonVariants } from "@/components/ui/button"; import { MealPlanner } from "@/components/meal-plan/meal-planner"; import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button"; @@ -52,7 +52,7 @@ export default async function MealPlanPage({ const sunday = addWeeks(monday, 1); sunday.setDate(sunday.getDate() - 1); - const [plan, userRecipes, sharedMemberships] = await Promise.all([ + const [plan, userRecipes, sharedMemberships, nutritionGoals] = await Promise.all([ db.query.mealPlans.findFirst({ where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)), with: { @@ -70,8 +70,16 @@ export default async function MealPlanPage({ where: eq(mealPlanMembers.userId, session.user.id), with: { mealPlan: { with: { user: true } } }, }), + db.query.userNutritionGoals.findFirst({ + where: eq(userNutritionGoals.userId, session.user.id), + }), ]); + const hasNutritionGoals = !!( + nutritionGoals && + (nutritionGoals.caloriesKcal || nutritionGoals.proteinG || nutritionGoals.carbsG || nutritionGoals.fatG) + ); + const entries = (plan?.entries ?? []).map((e) => ({ id: e.id, day: e.day, @@ -114,7 +122,7 @@ export default async function MealPlanPage({ - + {sharedMemberships.length > 0 && (
diff --git a/apps/web/app/(app)/nutrition/page.tsx b/apps/web/app/(app)/nutrition/page.tsx new file mode 100644 index 0000000..a22301f --- /dev/null +++ b/apps/web/app/(app)/nutrition/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { NutritionDiary } from "@/components/nutrition/nutrition-diary"; +import { getMessages } from "@/lib/i18n/server"; + +export const metadata: Metadata = {}; + +export default async function NutritionDiaryPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + + return ( +
+
+

{m.nutritionDiary.title}

+

{m.nutritionDiary.subtitle}

+
+ +
+ ); +} diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 48aaa2e..68de884 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -51,7 +51,7 @@ export default async function PantryPage() {
- + i.id).join(",")} initialItems={mappedItems} />
); } diff --git a/apps/web/app/(app)/recipes/[id]/cook/page.tsx b/apps/web/app/(app)/recipes/[id]/cook/page.tsx index c08cd36..c60d20d 100644 --- a/apps/web/app/(app)/recipes/[id]/cook/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/cook/page.tsx @@ -30,10 +30,13 @@ export default async function CookPage({ params }: Params) { if (!recipe || recipe.steps.length === 0) notFound(); + const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; + return ( ({ id: s.id, instruction: s.instruction, diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 77d7e9c..414edfa 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -58,6 +58,7 @@ export default async function RecipePage({ params }: Params) { const m = getMessages((session.user as { locale?: string }).locale); const VISIBILITY_LABEL = m.recipe.visibility; const DIETARY_LABELS = m.recipe.dietary; + const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote] = await Promise.all([ db.query.recipes.findFirst({ @@ -346,6 +347,7 @@ export default async function RecipePage({ params }: Params) { ({ id: ing.id, rawName: ing.rawName, diff --git a/apps/web/app/(app)/settings/nutrition/page.tsx b/apps/web/app/(app)/settings/nutrition/page.tsx index c5a3389..8b1c4c8 100644 --- a/apps/web/app/(app)/settings/nutrition/page.tsx +++ b/apps/web/app/(app)/settings/nutrition/page.tsx @@ -1,9 +1,12 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, userNutritionGoals, eq } from "@epicure/db"; import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form"; import { getMessages } from "@/lib/i18n/server"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; export const metadata: Metadata = {}; @@ -38,6 +41,15 @@ export default async function NutritionPage() { } /> +
+
+

{m.nutritionDiary.title}

+

{m.nutritionDiary.subtitle}

+
+ + {m.settings.nutritionGoals.viewDiaryCta} + +
); } diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index 9897135..dc7376a 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -9,10 +9,13 @@ import { recipes, userFollows, userBlocks, + cookingHistory, + ratings, eq, and, desc, count, + isNotNull, } from "@epicure/db"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -20,14 +23,32 @@ import { FollowButton } from "@/components/social/follow-button"; import { BlockButton } from "@/components/social/block-button"; import { MessageButton } from "@/components/social/message-button"; import { getPublicUrl } from "@/lib/storage"; +import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs"; const PAGE_SIZE = 24; type Params = { params: Promise<{ username: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ page?: string; tab?: string; ppage?: string }>; }; +/** Consecutive-day streak (UTC calendar days) ending today or yesterday. */ +function computeStreak(dates: Date[]): number { + const daySet = new Set(dates.map((d) => d.toISOString().slice(0, 10))); + const cursor = new Date(); + cursor.setUTCHours(0, 0, 0, 0); + if (!daySet.has(cursor.toISOString().slice(0, 10))) { + cursor.setUTCDate(cursor.getUTCDate() - 1); + if (!daySet.has(cursor.toISOString().slice(0, 10))) return 0; + } + let streak = 0; + while (daySet.has(cursor.toISOString().slice(0, 10))) { + streak++; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + return streak; +} + export async function generateMetadata({ params }: Params) { const { username } = await params; const user = await db.query.users.findFirst({ where: eq(users.username, username) }); @@ -36,9 +57,12 @@ export async function generateMetadata({ params }: Params) { export default async function UserProfilePage({ params, searchParams }: Params) { const { username } = await params; - const { page: pageParam } = await searchParams; + const { page: pageParam, tab: tabParam, ppage: ppageParam } = await searchParams; const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1); const offset = (page - 1) * PAGE_SIZE; + const photoPage = Math.max(1, parseInt(ppageParam ?? "1", 10) || 1); + const photoOffset = (photoPage - 1) * PAGE_SIZE; + const activeTab = tabParam === "history" || tabParam === "photos" ? tabParam : "recipes"; const session = await auth.api.getSession({ headers: await headers() }); @@ -96,6 +120,89 @@ export default async function UserProfilePage({ params, searchParams }: Params) isBlocked = !!blockRow; } + // Cooking history and "cooked it" photo gallery are only meaningful (and visible) to the + // profile owner: history reveals private behavioral/timing patterns, and photos may be + // attached to reviews of recipes that aren't visible to other viewers (private recipes), + // so surfacing them to non-owners could leak the existence of content they can't access. + let historyData: HistoryData | null = null; + let photosData: PhotosData | null = null; + + if (isOwnProfile) { + const [totalCookedRow, cookedDatesRows, lastCookedRow, recentEntries, totalPhotosRow, photoRows] = + await Promise.all([ + db.select({ count: count() }).from(cookingHistory).where(eq(cookingHistory.userId, user.id)), + db.query.cookingHistory.findMany({ + where: eq(cookingHistory.userId, user.id), + columns: { cookedAt: true }, + orderBy: desc(cookingHistory.cookedAt), + limit: 3650, + }), + db.query.cookingHistory.findFirst({ + where: eq(cookingHistory.userId, user.id), + orderBy: desc(cookingHistory.cookedAt), + with: { recipe: { columns: { id: true, title: true } } }, + }), + db.query.cookingHistory.findMany({ + where: eq(cookingHistory.userId, user.id), + orderBy: desc(cookingHistory.cookedAt), + limit: 20, + with: { recipe: { columns: { id: true, title: true } } }, + }), + db + .select({ count: count() }) + .from(ratings) + .where(and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey))), + db.query.ratings.findMany({ + where: and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey)), + orderBy: desc(ratings.createdAt), + limit: PAGE_SIZE, + offset: photoOffset, + with: { recipe: { columns: { id: true, title: true } } }, + }), + ]); + + const totalCooked = totalCookedRow[0]?.count ?? 0; + const streak = computeStreak(cookedDatesRows.map((r) => r.cookedAt)); + const totalPhotos = totalPhotosRow[0]?.count ?? 0; + + historyData = { + totalCooked, + streak, + lastCooked: + lastCookedRow && lastCookedRow.recipe + ? { + recipeId: lastCookedRow.recipe.id, + recipeTitle: lastCookedRow.recipe.title, + cookedAt: lastCookedRow.cookedAt.toISOString(), + } + : null, + recentEntries: recentEntries + .filter((e) => e.recipe) + .map((e) => ({ + id: e.id, + recipeId: e.recipe!.id, + recipeTitle: e.recipe!.title, + cookedAt: e.cookedAt.toISOString(), + servings: e.servings, + })), + }; + + photosData = { + items: photoRows + .filter((r) => r.recipe && r.photoKey) + .map((r) => ({ + id: r.id, + recipeId: r.recipe!.id, + recipeTitle: r.recipe!.title, + photoKey: r.photoKey!, + createdAt: r.createdAt.toISOString(), + })), + page: photoPage, + totalPages: Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), + total: totalPhotos, + }; + } + const initials = user.name .split(" ") .slice(0, 2) @@ -103,6 +210,72 @@ export default async function UserProfilePage({ params, searchParams }: Params) .join("") .toUpperCase(); + const recipesSection = + publicRecipes.length > 0 ? ( +
+

Recipes

+
+ {publicRecipes.map((recipe) => { + const cover = recipe.photos[0]; + return ( + +
+ {cover ? ( + {recipe.title} + ) : ( +
+ 🍽️ +
+ )} +
+
+

{recipe.title}

+
+ + ); + })} +
+ + {totalPages > 1 && ( +
+ {page > 1 && ( + 1 ? `?page=${page - 1}` : ""}`} + className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" + > + Previous + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next + + )} +
+ )} +
+ ) : ( +
+

No public recipes yet.

+
+ ); + return (
{/* Profile header */} @@ -149,70 +322,17 @@ export default async function UserProfilePage({ params, searchParams }: Params)
- {/* Recipe grid */} - {publicRecipes.length > 0 ? ( -
-

Recipes

-
- {publicRecipes.map((recipe) => { - const cover = recipe.photos[0]; - return ( - -
- {cover ? ( - {recipe.title} - ) : ( -
- 🍽️ -
- )} -
-
-

{recipe.title}

-
- - ); - })} -
- - {totalPages > 1 && ( -
- {page > 1 && ( - 1 ? `?page=${page - 1}` : ""}`} - className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" - > - Previous - - )} - - Page {page} of {totalPages} - - {page < totalPages && ( - - Next - - )} -
- )} -
+ {/* Recipes / cooking history / photos */} + {isOwnProfile && historyData && photosData ? ( + ) : ( -
-

No public recipes yet.

-
+ recipesSection )} ); diff --git a/apps/web/app/api/internal/cron/weekly-digest/route.ts b/apps/web/app/api/internal/cron/weekly-digest/route.ts new file mode 100644 index 0000000..bf55d6a --- /dev/null +++ b/apps/web/app/api/internal/cron/weekly-digest/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { + db, + users, + recipes, + comments, + ratings, + userFollows, + favorites, + eq, + and, + gte, + desc, + count, + sql, +} from "@epicure/db"; +import { sendEmail, weeklyDigestHtml } from "@/lib/email"; + +// Internal cron endpoint — triggered by the `digest-cron` container on a weekly +// schedule (see docker/compose.prod.yml). Not part of the public API surface; +// protected by a shared secret rather than user auth. +// +// Computes, for every user: new followers / new comments / new ratings on +// their recipes in the last 7 days, plus a site-wide top-3 trending list, and +// emails a summary. Sends to all users (all users have a non-null email) — +// there's no per-user opt-out preference yet; out of scope for this pass. + +const CHUNK_SIZE = 20; + +function isAuthorized(req: NextRequest): boolean { + const secret = process.env["CRON_SECRET"]; + if (!secret) return false; + + const header = req.headers.get("authorization"); + if (!header?.startsWith("Bearer ")) return false; + const provided = header.slice("Bearer ".length); + + const a = Buffer.from(provided); + const b = Buffer.from(secret); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +export async function POST(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; + + const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([ + db.select({ id: users.id, email: users.email }).from(users), + db + .select({ userId: userFollows.followingId, n: count() }) + .from(userFollows) + .where(gte(userFollows.createdAt, weekAgo)) + .groupBy(userFollows.followingId), + db + .select({ userId: recipes.authorId, n: count() }) + .from(comments) + .innerJoin(recipes, eq(comments.recipeId, recipes.id)) + .where(gte(comments.createdAt, weekAgo)) + .groupBy(recipes.authorId), + db + .select({ userId: recipes.authorId, n: count() }) + .from(ratings) + .innerJoin(recipes, eq(ratings.recipeId, recipes.id)) + .where(gte(ratings.createdAt, weekAgo)) + .groupBy(recipes.authorId), + db + .select({ + id: recipes.id, + title: recipes.title, + favoriteCount: sql`cast(count(${favorites.recipeId}) as int)`, + }) + .from(recipes) + .leftJoin( + favorites, + and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, weekAgo)) + ) + .where(eq(recipes.visibility, "public")) + .groupBy(recipes.id) + .orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt)) + .limit(3), + ]); + + const followerMap = new Map(followerRows.map((r) => [r.userId, r.n])); + const commentMap = new Map(commentRows.map((r) => [r.userId, r.n])); + const ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n])); + const trendingList = trending.map((r) => ({ id: r.id, title: r.title })); + + let sent = 0; + let failed = 0; + + for (const batch of chunk(allUsers, CHUNK_SIZE)) { + const results = await Promise.allSettled( + batch.map((user) => { + const newFollowers = followerMap.get(user.id) ?? 0; + const newComments = commentMap.get(user.id) ?? 0; + const newRatings = ratingMap.get(user.id) ?? 0; + + return sendEmail({ + to: user.email, + subject: "Your weekly digest — Epicure", + html: weeklyDigestHtml({ + newFollowers, + newComments, + newRatings, + trending: trendingList, + baseUrl, + }), + }); + }) + ); + + for (const r of results) { + if (r.status === "fulfilled") sent++; + else failed++; + } + } + + return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed }); +} diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index d968b3f..cf49ee1 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, eq, and } from "@epicure/db"; +import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, eq, and } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; @@ -19,6 +19,7 @@ const Schema = z.object({ usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), + targetNutritionGoals: z.boolean().default(false), }); export async function POST(req: NextRequest) { @@ -56,6 +57,24 @@ export async function POST(req: NextRequest) { pantryItemNames = pantry.map((p) => p.rawName); } + // Optionally fetch the user's nutrition goals to nudge the AI toward them. + // Silently ignored (no-op) if the user hasn't set any goals — no need to + // fail the whole generation over a missing preference. + let nutritionGoals: { caloriesKcal?: number | null; proteinG?: number | null; carbsG?: number | null; fatG?: number | null } | undefined; + if (parsed.data.targetNutritionGoals) { + const goals = await db.query.userNutritionGoals.findFirst({ + where: eq(userNutritionGoals.userId, userId), + }); + if (goals && (goals.caloriesKcal || goals.proteinG || goals.carbsG || goals.fatG)) { + nutritionGoals = { + caloriesKcal: goals.caloriesKcal, + proteinG: goals.proteinG, + carbsG: goals.carbsG, + fatG: goals.fatG, + }; + } + } + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => generateMealPlan( { @@ -65,6 +84,7 @@ export async function POST(req: NextRequest) { days: parsed.data.days, pantryMode: parsed.data.pantryMode, difficulty: parsed.data.difficulty, + nutritionGoals, }, { ...config, userContext: privateBio ?? undefined }, locale diff --git a/apps/web/app/api/v1/pantry/scan/barcode/route.ts b/apps/web/app/api/v1/pantry/scan/barcode/route.ts new file mode 100644 index 0000000..e53df6d --- /dev/null +++ b/apps/web/app/api/v1/pantry/scan/barcode/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; + +const Schema = z.object({ + barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/, "Barcode must be numeric"), +}); + +type OffProduct = { + product_name?: string; + product_name_en?: string; + generic_name?: string; + quantity?: string; + product_quantity?: string; + product_quantity_unit?: string; + brands?: string; +}; + +type OffResponse = { + status: number; + product?: OffProduct; +}; + +/** Very rough unit guess from Open Food Facts' free-text `quantity` field (e.g. "500 g", "1 L"). */ +function extractUnit(quantity: string | undefined): string | undefined { + if (!quantity) return undefined; + const match = /([a-zA-Z]+)\s*$/.exec(quantity.trim()); + return match?.[1]?.toLowerCase(); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const limited = await applyRateLimit(`rl:pantry-scan-barcode:${session!.user.id}`, 20, 60); + if (limited) return limited; + + const { barcode } = parsed.data; + + let offResponse: Response; + try { + offResponse = await fetch( + `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json`, + { + headers: { "User-Agent": "Epicure/1.0 (pantry-scan)" }, + signal: AbortSignal.timeout(8000), + } + ); + } catch { + return NextResponse.json({ error: "Barcode lookup service unavailable" }, { status: 502 }); + } + + if (!offResponse.ok) { + return NextResponse.json({ error: "Barcode lookup service unavailable" }, { status: 502 }); + } + + const data = await offResponse.json() as OffResponse; + + if (data.status !== 1 || !data.product) { + return NextResponse.json({ found: false }); + } + + const product = data.product; + const rawName = product.product_name_en?.trim() || product.product_name?.trim() || product.generic_name?.trim(); + + if (!rawName) { + return NextResponse.json({ found: false }); + } + + return NextResponse.json({ + found: true, + rawName: product.brands ? `${rawName} (${product.brands.split(",")[0]?.trim()})` : rawName, + quantity: product.product_quantity, + unit: extractUnit(product.product_quantity_unit) ?? extractUnit(product.quantity), + }); +} diff --git a/apps/web/app/api/v1/pantry/scan/photo/route.ts b/apps/web/app/api/v1/pantry/scan/photo/route.ts new file mode 100644 index 0000000..7542e15 --- /dev/null +++ b/apps/web/app/api/v1/pantry/scan/photo/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; +import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key"; +import { scanPantryPhoto } from "@/lib/ai/features/scan-pantry-photo"; + +const Schema = z.object({ + imageBase64: z.string().max(14_000_000), + mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]), +}); + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); + } + + const userId = session!.user.id; + + const limited = await applyRateLimit(`rl:ai:${userId}`, 10, 60); + if (limited) return limited; + + const configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision")); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + + // Fall back to vision-capable defaults if no explicit model configured + if (!aiConfig.model) { + if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o"; + else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6"; + } + + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => + scanPantryPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig) + ); + if (!result.ok) return result.response; + + return NextResponse.json(result.data); +} 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 new file mode 100644 index 0000000..8ad5441 --- /dev/null +++ b/apps/web/app/api/v1/users/me/nutrition-diary/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, cookingHistory, recipes, userNutritionGoals, eq, and, gte, lt } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +function isValidDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(new Date(`${value}T00:00:00.000Z`).getTime()); +} + +export async function GET(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + const userId = session!.user.id; + + const dateParam = req.nextUrl.searchParams.get("date"); + const date = dateParam && isValidDate(dateParam) ? dateParam : new Date().toISOString().slice(0, 10); + + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + const rows = await db + .select({ + id: cookingHistory.id, + recipeId: cookingHistory.recipeId, + servings: cookingHistory.servings, + cookedAt: cookingHistory.cookedAt, + recipeTitle: recipes.title, + baseServings: recipes.baseServings, + nutritionData: recipes.nutritionData, + }) + .from(cookingHistory) + .leftJoin(recipes, eq(cookingHistory.recipeId, recipes.id)) + .where( + and( + eq(cookingHistory.userId, userId), + gte(cookingHistory.cookedAt, dayStart), + lt(cookingHistory.cookedAt, dayEnd) + ) + ) + .orderBy(cookingHistory.cookedAt); + + const totals = { calories: 0, proteinG: 0, carbsG: 0, fatG: 0, fiberG: 0, sodiumMg: 0 }; + const entries: { + id: string; + recipeId: string; + title: string; + servings: number; + cookedAt: string; + nutritionKnown: boolean; + }[] = []; + let unknownCount = 0; + + for (const row of rows) { + const servings = row.servings ?? row.baseServings ?? 1; + const perServing = row.nutritionData?.perServing; + const nutritionKnown = !!perServing; + + if (perServing) { + totals.calories += perServing.calories * servings; + totals.proteinG += perServing.proteinG * servings; + totals.carbsG += perServing.carbsG * servings; + totals.fatG += perServing.fatG * servings; + totals.fiberG += perServing.fiberG * servings; + totals.sodiumMg += perServing.sodiumMg * servings; + } else { + unknownCount += 1; + } + + entries.push({ + id: row.id, + recipeId: row.recipeId, + title: row.recipeTitle ?? "Unknown recipe", + servings, + cookedAt: row.cookedAt.toISOString(), + nutritionKnown, + }); + } + + for (const key of Object.keys(totals) as (keyof typeof totals)[]) { + totals[key] = Math.round(totals[key]); + } + + const goalsRow = await db.query.userNutritionGoals.findFirst({ + where: eq(userNutritionGoals.userId, userId), + }); + + const goals = goalsRow + ? { + caloriesKcal: goalsRow.caloriesKcal, + proteinG: goalsRow.proteinG, + carbsG: goalsRow.carbsG, + fatG: goalsRow.fatG, + } + : null; + + const coverage = { + calories: goals?.caloriesKcal ? Math.round((totals.calories / goals.caloriesKcal) * 100) : 0, + protein: goals?.proteinG ? Math.round((totals.proteinG / goals.proteinG) * 100) : 0, + carbs: goals?.carbsG ? Math.round((totals.carbsG / goals.carbsG) * 100) : 0, + fat: goals?.fatG ? Math.round((totals.fatG / goals.fatG) * 100) : 0, + }; + + return NextResponse.json({ date, totals, goals, coverage, entries, unknownCount }); +} diff --git a/apps/web/app/print/[id]/page.tsx b/apps/web/app/print/[id]/page.tsx index 990e545..79c2d1c 100644 --- a/apps/web/app/print/[id]/page.tsx +++ b/apps/web/app/print/[id]/page.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, recipes, eq, and } from "@epicure/db"; import { PrintTrigger } from "@/components/recipe/print-trigger"; -import { hasQuantity } from "@/lib/fractions"; +import { formatIngredientQuantity } from "@/lib/unit-conversion"; import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -14,6 +14,7 @@ export default async function RecipePrintPage({ params }: Params) { if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); + const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; const recipe = await db.query.recipes.findFirst({ where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)), @@ -103,7 +104,7 @@ export default async function RecipePrintPage({ params }: Params) { {recipe.ingredients.map((ing) => (
  • - {[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")} + {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)} {ing.rawName} {ing.note && ({ing.note})} diff --git a/apps/web/app/print/collection/[id]/page.tsx b/apps/web/app/print/collection/[id]/page.tsx index d437ae2..1bea56b 100644 --- a/apps/web/app/print/collection/[id]/page.tsx +++ b/apps/web/app/print/collection/[id]/page.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, collections, eq, and, or } from "@epicure/db"; import { PrintTrigger } from "@/components/recipe/print-trigger"; -import { hasQuantity } from "@/lib/fractions"; +import { formatIngredientQuantity } from "@/lib/unit-conversion"; import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -14,6 +14,7 @@ export default async function CollectionPrintPage({ params }: Params) { if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); + const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; const col = await db.query.collections.findFirst({ where: and( @@ -113,7 +114,7 @@ export default async function CollectionPrintPage({ params }: Params) { {recipe.ingredients.map((ing) => (
  • - {[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")} + {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)} {ing.rawName} {ing.note && ({ing.note})} diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 6c7ad9f..08b3a0a 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -40,6 +40,7 @@ export default async function PublicRecipePage({ params }: Params) { const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); const m = getMessages((session?.user as { locale?: string } | undefined)?.locale); + const unitPref = (session?.user as { unitPref?: string } | undefined)?.unitPref === "imperial" ? "imperial" : "metric"; const recipe = await db.query.recipes.findFirst({ where: (r, { and, eq, ne }) => and(eq(r.id, id), ne(r.visibility, "private")), @@ -137,6 +138,7 @@ export default async function PublicRecipePage({ params }: Params) {

    {m.recipe.ingredients}

    ({ id: ing.id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order, }))} diff --git a/apps/web/components/cooking-mode/cooking-mode.tsx b/apps/web/components/cooking-mode/cooking-mode.tsx index 74c5344..e671fa7 100644 --- a/apps/web/components/cooking-mode/cooking-mode.tsx +++ b/apps/web/components/cooking-mode/cooking-mode.tsx @@ -6,7 +6,7 @@ import { X, ChevronLeft, ChevronRight, List, Mic, MicOff, Play, Square, HelpCirc import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useLocale } from "@/lib/i18n/provider"; -import { hasQuantity } from "@/lib/fractions"; +import { formatIngredientQuantity, type UnitPref } from "@/lib/unit-conversion"; import { useWakeLock } from "@/lib/hooks/use-wake-lock"; type Step = { id: string; instruction: string; timerSeconds: number | null }; @@ -68,11 +68,13 @@ export function CookingMode({ recipeTitle, steps, ingredients, + unitPref = "metric", }: { recipeId: string; recipeTitle: string; steps: Step[]; ingredients: Ingredient[]; + unitPref?: UnitPref; }) { const router = useRouter(); const t = useTranslations("cookingMode"); @@ -342,7 +344,7 @@ export function CookingMode({ {ingredients.map((ing, i) => (
  • - {hasQuantity(ing.quantity) ? ing.quantity : ""}{ing.unit ? ` ${ing.unit}` : ""} + {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)} {ing.rawName}
  • diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index 40da25b..c19ebed 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; -import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon } from "lucide-react"; +import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Apple } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -33,6 +33,7 @@ const NAV_ITEMS = [ { href: "/feed", key: "feed", icon: Rss }, { href: "/collections", key: "collections", icon: FolderOpen }, { href: "/meal-plan", key: "mealPlan", icon: Calendar }, + { href: "/nutrition", key: "nutrition", icon: Apple }, { href: "/pantry", key: "pantry", icon: Package }, { href: "/shopping-lists", key: "shopping", icon: ShoppingCart }, ] as const; diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx index bd46c7b..ab3f54e 100644 --- a/apps/web/components/meal-plan/meal-planner.tsx +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -117,10 +117,12 @@ export function MealPlanner({ weekStart, initialEntries, userRecipes, + hasNutritionGoals = false, }: { weekStart: string; initialEntries: Entry[]; userRecipes: UserRecipe[]; + hasNutritionGoals?: boolean; }) { const [entries, setEntries] = useState(initialEntries); const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null); @@ -132,6 +134,7 @@ export function MealPlanner({ const [usePantry, setUsePantry] = useState(true); const [pantryMode, setPantryMode] = useState(false); const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); + const [targetNutritionGoals, setTargetNutritionGoals] = useState(false); const t = useTranslations("mealPlan"); const tRecipe = useTranslations("recipe"); @@ -158,6 +161,7 @@ export function MealPlanner({ usePantry, pantryMode, difficulty: aiDifficulty || undefined, + targetNutritionGoals: hasNutritionGoals && targetNutritionGoals, }), }); if (!res.ok) { @@ -390,6 +394,21 @@ export function MealPlanner({ )} + {hasNutritionGoals && ( + + )}
    + router.refresh()} />
    {/* Item list */} diff --git a/apps/web/components/nutrition/nutrition-diary.tsx b/apps/web/components/nutrition/nutrition-diary.tsx new file mode 100644 index 0000000..a7f33c7 --- /dev/null +++ b/apps/web/components/nutrition/nutrition-diary.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type NutritionTotals = { + calories: number; + proteinG: number; + carbsG: number; + fatG: number; + fiberG: number; + sodiumMg: number; +}; + +type NutritionGoals = { + caloriesKcal: number | null; + proteinG: number | null; + carbsG: number | null; + fatG: number | null; +} | null; + +type NutritionCoverage = { + calories: number; + protein: number; + carbs: number; + fat: number; +}; + +type DiaryEntry = { + id: string; + recipeId: string; + title: string; + servings: number; + cookedAt: string; + nutritionKnown: boolean; +}; + +type DiaryResponse = { + date: string; + totals: NutritionTotals; + goals: NutritionGoals; + coverage: NutritionCoverage; + entries: DiaryEntry[]; + unknownCount: number; +}; + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +export function NutritionDiary() { + const t = useTranslations("nutritionDiary"); + const [date, setDate] = useState(todayIso()); + // Tagged with the date it was fetched for, and set only from inside the + // fetch's own then/catch — never synchronously in the effect body — so we + // don't need a separate "loading" setState call at the top of the effect. + const [result, setResult] = useState< + { date: string; ok: true; data: DiaryResponse } | { date: string; ok: false } | null + >(null); + + useEffect(() => { + let cancelled = false; + fetch(`/api/v1/users/me/nutrition-diary?date=${date}`) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error("request failed")))) + .then((json: DiaryResponse) => { + if (!cancelled) setResult({ date, ok: true, data: json }); + }) + .catch(() => { + if (!cancelled) setResult({ date, ok: false }); + }); + return () => { + cancelled = true; + }; + }, [date]); + + const loading = result === null || result.date !== date; + const error = !loading && result !== null && !result.ok; + const data = !loading && result !== null && result.ok ? result.data : null; + + const bars = data + ? [ + { + label: t("calories"), + value: data.totals.calories, + goal: data.goals?.caloriesKcal ?? null, + unit: "kcal", + percentage: data.coverage.calories, + colorClass: "bg-orange-500", + }, + { + label: t("protein"), + value: data.totals.proteinG, + goal: data.goals?.proteinG ?? null, + unit: "g", + percentage: data.coverage.protein, + colorClass: "bg-blue-500", + }, + { + label: t("carbs"), + value: data.totals.carbsG, + goal: data.goals?.carbsG ?? null, + unit: "g", + percentage: data.coverage.carbs, + colorClass: "bg-green-500", + }, + { + label: t("fat"), + value: data.totals.fatG, + goal: data.goals?.fatG ?? null, + unit: "g", + percentage: data.coverage.fat, + colorClass: "bg-yellow-500", + }, + ] + : []; + + return ( +
    +
    +
    + + setDate(e.target.value || todayIso())} + className="w-auto" + /> +
    + {date !== todayIso() && ( + + )} +
    + + {loading &&

    {t("loading")}

    } + {error &&

    {t("loadError")}

    } + + {!loading && !error && data && ( + <> + {!data.goals && ( + + +

    {t("goalNotSet")}

    + + {t("setGoalsCta")} + +
    +
    + )} + + {data.goals && ( + + + {bars.map((bar) => + bar.goal == null ? null : ( +
    +
    + {bar.label} + + {bar.value} / {bar.goal} {bar.unit} + +
    +
    +
    +
    +
    + ) + )} +
    +
    + {t("fiber")} + {data.totals.fiberG}g +
    +
    + {t("sodium")} + {data.totals.sodiumMg}mg +
    +
    + + + )} + +
    +

    {t("entriesTitle")}

    + {data.entries.length === 0 ? ( +

    {t("noEntries")}

    + ) : ( +
      + {data.entries.map((entry) => ( +
    • + + {entry.title} + + + {t("servingsLabel", { count: entry.servings })} + {!entry.nutritionKnown && ( + + {t("nutritionUnknownBadge")} + + )} + +
    • + ))} +
    + )} + {data.unknownCount > 0 && ( +

    {t("unknownNote", { count: data.unknownCount })}

    + )} +
    + + )} +
    + ); +} diff --git a/apps/web/components/pantry/barcode-scanner.tsx b/apps/web/components/pantry/barcode-scanner.tsx new file mode 100644 index 0000000..1165e57 --- /dev/null +++ b/apps/web/components/pantry/barcode-scanner.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Loader2 } from "lucide-react"; + +export function BarcodeScanner({ + onDetected, + onError, +}: { + onDetected: (barcode: string) => void; + onError?: (message: string) => void; +}) { + const t = useTranslations("pantry"); + const videoRef = useRef(null); + const [starting, setStarting] = useState(true); + const detectedRef = useRef(false); + + useEffect(() => { + let cancelled = false; + let controls: { stop: () => void } | null = null; + + async function start() { + try { + const { BrowserMultiFormatReader } = await import("@zxing/browser"); + const reader = new BrowserMultiFormatReader(); + if (cancelled || !videoRef.current) return; + + controls = await reader.decodeFromVideoDevice( + undefined, + videoRef.current, + (result) => { + if (result && !detectedRef.current) { + detectedRef.current = true; + onDetected(result.getText()); + controls?.stop(); + } + } + ); + if (!cancelled) setStarting(false); + } catch (err) { + if (!cancelled) { + onError?.(err instanceof Error ? err.message : t("cameraError")); + setStarting(false); + } + } + } + + void start(); + + return () => { + cancelled = true; + controls?.stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
    +